From f38329189fddd5aa62d6e753474281fa77e57074 Mon Sep 17 00:00:00 2001 From: Josh Eckels Date: Fri, 22 May 2026 14:13:32 -0700 Subject: [PATCH 01/17] Partial backport of platform PR #7435 (#7686) #### Rationale Simplify the `study` schema's queries. #### Changes - Remove unused table --- .../IVisualizationSourceQuery.java | 2 - .../labkey/study/query/StudyQuerySchema.java | 22 ---- .../query/VisualizationVisitTagTable.java | 104 ------------------ .../StudyVisualizationProvider.java | 41 ++++--- .../sql/OuterJoinSourceQuery.java | 6 - .../sql/VisualizationSourceQuery.java | 17 +-- 6 files changed, 24 insertions(+), 168 deletions(-) diff --git a/api/src/org/labkey/api/visualization/IVisualizationSourceQuery.java b/api/src/org/labkey/api/visualization/IVisualizationSourceQuery.java index 0fc293bb331..9c017057af5 100644 --- a/api/src/org/labkey/api/visualization/IVisualizationSourceQuery.java +++ b/api/src/org/labkey/api/visualization/IVisualizationSourceQuery.java @@ -63,8 +63,6 @@ public interface IVisualizationSourceQuery boolean isSkipVisitJoin(); - boolean isVisitTagQuery(); - /** * True if any select or aggregate requires a left join explicitly. This is an override for any columns * that might require some form of an INNER JOIN. diff --git a/study/src/org/labkey/study/query/StudyQuerySchema.java b/study/src/org/labkey/study/query/StudyQuerySchema.java index a5fbb61149e..83a2fb67591 100644 --- a/study/src/org/labkey/study/query/StudyQuerySchema.java +++ b/study/src/org/labkey/study/query/StudyQuerySchema.java @@ -127,7 +127,6 @@ public class StudyQuerySchema extends UserSchema implements UserSchema.HasContex public static final String VISIT_TAG_TABLE_NAME = "VisitTag"; public static final String VISIT_TAG_MAP_TABLE_NAME = "VisitTagMap"; public static final String VISIT_ALIASES = "VisitAliases"; - public static final String VISUALIZATION_VISIT_TAG_TABLE_NAME = "VisualizationVisitTag"; public static final String VISIT_MAP_TABLE_NAME = "VisitMap"; public static final String STUDY_DATA_TABLE_NAME = "StudyData"; @@ -645,27 +644,6 @@ public TableInfo createTable(String name, ContainerFilter cf) { return new VisitMapTable(this, cf); } - if (name.startsWith(VISUALIZATION_VISIT_TAG_TABLE_NAME)) - { - // Name is encoded with useProtocolDay boolean, tagName, and altQueryName - String params = name.substring(VISUALIZATION_VISIT_TAG_TABLE_NAME.length()); - boolean useProtocolDay; - if (params.startsWith("-true")) - { - params = params.substring(params.indexOf("-true") + 6); - useProtocolDay = true; - } - else - { - params = params.substring(params.indexOf("-false") + 7); - useProtocolDay = false; - } - int hyphenIndex = params.indexOf("-"); - String tagName = hyphenIndex > -1 ? params.substring(0, hyphenIndex) : params; - String altQueryName = hyphenIndex > -1 ? params.substring(hyphenIndex + 1) : null; - - return new VisualizationVisitTagTable(this, cf, getStudy(), getUser(), tagName, useProtocolDay, altQueryName); - } // Might be a dataset DatasetDefinition dsd = getDatasetDefinitionByQueryName(name); diff --git a/study/src/org/labkey/study/query/VisualizationVisitTagTable.java b/study/src/org/labkey/study/query/VisualizationVisitTagTable.java index b494591ef6e..e69de29bb2d 100644 --- a/study/src/org/labkey/study/query/VisualizationVisitTagTable.java +++ b/study/src/org/labkey/study/query/VisualizationVisitTagTable.java @@ -1,104 +0,0 @@ -/* - * Copyright (c) 2014-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.study.query; - -import org.jetbrains.annotations.NotNull; -import org.labkey.api.data.ContainerFilter; -import org.labkey.api.data.JdbcType; -import org.labkey.api.data.SQLFragment; -import org.labkey.api.data.VirtualTable; -import org.labkey.api.query.ExprColumn; -import org.labkey.api.security.User; -import org.labkey.api.study.StudyService; -import org.labkey.study.model.StudyImpl; - -public class VisualizationVisitTagTable extends VirtualTable -{ - private final StudyImpl _study; - private final User _user; - private final boolean _useProtocolDay; - private final String _visitTagName; - private final String _dayString; - private final String _altQueryName; - - public VisualizationVisitTagTable(StudyQuerySchema schema, ContainerFilter cf, StudyImpl study, User user, String visitTagName, boolean useProtocolDay, String altQueryName) - { - super(schema.getDbSchema(), "VizVisitTag", schema, cf); - _study = study; - _user = user; - _visitTagName = visitTagName; - _useProtocolDay = useProtocolDay; - if (_useProtocolDay) - _dayString = "ProtocolDay"; - else - _dayString = "Day"; - _altQueryName = altQueryName; - - addColumn(new ExprColumn(this, StudyService.get().getSubjectColumnName(_study.getContainer()), - new SQLFragment(ExprColumn.STR_TABLE_ALIAS + "." + "ParticipantId"), JdbcType.VARCHAR)); - - // 20546: need to expose Container for use in StudyVisualizationProvider.getJoinColumns - addColumn(new ExprColumn(this, "Container", new SQLFragment(ExprColumn.STR_TABLE_ALIAS + "." + "Container"), JdbcType.VARCHAR)); - - addColumn(new ExprColumn(this, "ZeroDay", new SQLFragment(ExprColumn.STR_TABLE_ALIAS + "." + _dayString), JdbcType.INTEGER)); - } - - @NotNull - @Override - public SQLFragment getFromSQL(String alias) - { - checkReadBeforeExecute(); - if (_altQueryName == null) - { - String innerAlias = alias + "_SP"; - String joinString; - if (_useProtocolDay) - { - joinString = "\nJOIN study.Visit ON " + innerAlias + ".VisitId = study.Visit.RowId AND " + - innerAlias + ".VisitId IS NOT NULL"; - } - else - { - joinString = "\nJOIN study.ParticipantVisit ON " + innerAlias + ".VisitId = study.ParticipantVisit.VisitRowId AND " + - innerAlias + ".ParticipantId = study.ParticipantVisit.ParticipantId"; - } - - SQLFragment from = new SQLFragment("(SELECT VisitId, " + _dayString + ", " + innerAlias + ".ParticipantId, Container " + " FROM (SELECT ParticipantId, "); - from.append("COALESCE(CohortVisitTag.VisitId, NoCohortTag.VisitId) As VisitId, CurrentCohortId FROM study.Participant\n") - .append("LEFT OUTER JOIN study.VisitTagMap CohortVisitTag ON study.Participant.CurrentCohortId = CohortVisitTag.CohortID AND CohortVisitTag.VisitTag=") - .append("'" + _visitTagName + "'\n") - .append("AND CohortVisitTag.Container = Participant.Container"); - from.append("\nLEFT OUTER JOIN study.VisitTagMap NoCohortTag ON NoCohortTag.VisitTag =") - .append("'" + _visitTagName + "' AND NoCohortTag.CohortID IS NULL\n") - .append("AND NoCohortTag.Container = Participant.Container"); - from.append("\nWHERE "); - - // TODO: this is a temp fix for the Dataspace usecase - from.append(ContainerFilter.Type.AllInProject.create(_study.getContainer(), _user).getSQLFragment(getSchema(), new SQLFragment("study.Participant.Container"))); - - from.append(") ").appendIdentifier(innerAlias); - from.append(joinString); - from.append("\n) ").appendIdentifier(alias); - return from; - } - else - { - // allow caller to pass in their own query to use for the SQL to get the participant-to-zero day map (used for CDS study axis alignment by visit tag) - // NOTE: it is assumed that the query will have the expected columns plus a column for VisitTagMap to filter on - return new SQLFragment("(SELECT * FROM " + _altQueryName + " WHERE VisitTagName = '" + _visitTagName + "')").appendIdentifier(alias); - } - } -} diff --git a/study/src/org/labkey/study/visualization/StudyVisualizationProvider.java b/study/src/org/labkey/study/visualization/StudyVisualizationProvider.java index 445a8eb9fff..353feb1e7dc 100644 --- a/study/src/org/labkey/study/visualization/StudyVisualizationProvider.java +++ b/study/src/org/labkey/study/visualization/StudyVisualizationProvider.java @@ -177,31 +177,28 @@ public List> getJoinC joinCols.add(new Pair<>(firstSubjectCol, secondSubjectCol)); - if (!first.isVisitTagQuery() && ! second.isVisitTagQuery()) - { - // attempt to lookup the dataset using the queryName by label and then by name - Dataset firstDataset = StudyService.get().resolveDataset(first.getContainer(), first.getQueryName()); - Dataset secondDataset = StudyService.get().resolveDataset(second.getContainer(), second.getQueryName()); + // attempt to lookup the dataset using the queryName by label and then by name + Dataset firstDataset = StudyService.get().resolveDataset(first.getContainer(), first.getQueryName()); + Dataset secondDataset = StudyService.get().resolveDataset(second.getContainer(), second.getQueryName()); - boolean subjectJoinOnly = isGroupByQuery || first.isSkipVisitJoin() || second.isSkipVisitJoin(); + boolean subjectJoinOnly = isGroupByQuery || first.isSkipVisitJoin() || second.isSkipVisitJoin(); - // if either query is a demographic dataset, it's sufficient to join on subject only: - if (!subjectJoinOnly && (firstDataset == null || firstDataset.getKeyType() != Dataset.KeyType.SUBJECT) && - (secondDataset == null || secondDataset.getKeyType() != Dataset.KeyType.SUBJECT)) + // if either query is a demographic dataset, it's sufficient to join on subject only: + if (!subjectJoinOnly && (firstDataset == null || firstDataset.getKeyType() != Dataset.KeyType.SUBJECT) && + (secondDataset == null || secondDataset.getKeyType() != Dataset.KeyType.SUBJECT)) + { + VisualizationSourceColumn firstSequenceCol = getVisitJoinColumn(factory, first, firstSubjectNounSingular); + VisualizationSourceColumn secondSequenceCol = getVisitJoinColumn(factory, second, secondSubjectNounSingular); + joinCols.add(new Pair<>(firstSequenceCol, secondSequenceCol)); + + // for datasets with matching 3rd keys, join on subject/visit/key (if neither are pivoted), allowing null results for this column so as to follow the lead of the primary measure column for this query: + if (firstDataset != null && firstDataset.getKeyType() == Dataset.KeyType.SUBJECT_VISIT_OTHER && + secondDataset != null && secondDataset.getKeyType() == Dataset.KeyType.SUBJECT_VISIT_OTHER && + first.getPivot() == null && second.getPivot() == null && firstDataset.hasMatchingExtraKey(secondDataset)) { - VisualizationSourceColumn firstSequenceCol = getVisitJoinColumn(factory, first, firstSubjectNounSingular); - VisualizationSourceColumn secondSequenceCol = getVisitJoinColumn(factory, second, secondSubjectNounSingular); - joinCols.add(new Pair<>(firstSequenceCol, secondSequenceCol)); - - // for datasets with matching 3rd keys, join on subject/visit/key (if neither are pivoted), allowing null results for this column so as to follow the lead of the primary measure column for this query: - if (firstDataset != null && firstDataset.getKeyType() == Dataset.KeyType.SUBJECT_VISIT_OTHER && - secondDataset != null && secondDataset.getKeyType() == Dataset.KeyType.SUBJECT_VISIT_OTHER && - first.getPivot() == null && second.getPivot() == null && firstDataset.hasMatchingExtraKey(secondDataset)) - { - VisualizationSourceColumn firstKeyCol = factory.create(first.getSchema(), first.getQueryName(), firstDataset.getKeyPropertyName(), true); - VisualizationSourceColumn secondKeyCol = factory.create(second.getSchema(), second.getQueryName(), secondDataset.getKeyPropertyName(), true); - joinCols.add(new Pair<>(firstKeyCol, secondKeyCol)); - } + VisualizationSourceColumn firstKeyCol = factory.create(first.getSchema(), first.getQueryName(), firstDataset.getKeyPropertyName(), true); + VisualizationSourceColumn secondKeyCol = factory.create(second.getSchema(), second.getQueryName(), secondDataset.getKeyPropertyName(), true); + joinCols.add(new Pair<>(firstKeyCol, secondKeyCol)); } } diff --git a/visualization/src/org/labkey/visualization/sql/OuterJoinSourceQuery.java b/visualization/src/org/labkey/visualization/sql/OuterJoinSourceQuery.java index 5b4b214e07c..e955a3d1554 100644 --- a/visualization/src/org/labkey/visualization/sql/OuterJoinSourceQuery.java +++ b/visualization/src/org/labkey/visualization/sql/OuterJoinSourceQuery.java @@ -235,12 +235,6 @@ public boolean isSkipVisitJoin() return false; } - @Override - public boolean isVisitTagQuery() - { - return false; - } - @Override public boolean isRequireLeftJoin() { diff --git a/visualization/src/org/labkey/visualization/sql/VisualizationSourceQuery.java b/visualization/src/org/labkey/visualization/sql/VisualizationSourceQuery.java index ba0b0901c54..9827a6e2410 100644 --- a/visualization/src/org/labkey/visualization/sql/VisualizationSourceQuery.java +++ b/visualization/src/org/labkey/visualization/sql/VisualizationSourceQuery.java @@ -131,12 +131,6 @@ public boolean requireInnerJoin() return false; } - @Override - public boolean isVisitTagQuery() - { - return _queryName.startsWith("VisualizationVisitTag"); - } - @Override public boolean isRequireLeftJoin() { @@ -213,7 +207,7 @@ public String getSelectListName(Set selectAliases) private static void addToColMap(Map> colMap, String name, VisualizationSourceColumn alias) { - Set aliases = colMap.computeIfAbsent(name, k -> new LinkedHashSet<>()); + Set aliases = colMap.computeIfAbsent(name, n -> new LinkedHashSet<>()); aliases.add(alias); } @@ -434,7 +428,7 @@ public String getGroupByClause() return ""; } - private void appendValueList(StringBuilder sql, VisualizationSourceColumn col) throws org.labkey.api.visualization.SQLGenerationException + private void appendValueList(StringBuilder sql, VisualizationSourceColumn col) { if (col.getValues() != null && !col.getValues().isEmpty()) { @@ -457,7 +451,7 @@ private void appendValueList(StringBuilder sql, VisualizationSourceColumn col) t } } - public String getPivotClause() throws org.labkey.api.visualization.SQLGenerationException + public String getPivotClause() { if (_pivot != null) { @@ -560,7 +554,7 @@ private String appendSimpleFilter(StringBuilder where, SimpleFilter filter, Stri return separator; } - public String getWhereClause() throws org.labkey.api.visualization.SQLGenerationException + public String getWhereClause() { StringBuilder where = new StringBuilder(); String sep = "WHERE "; @@ -598,12 +592,11 @@ public String getWhereClause() throws org.labkey.api.visualization.SQLGeneration @Override public String getSQL(VisualizationSourceColumn.Factory factory) throws SQLGenerationException { - String sql = getSelectClause(factory) + "\n" + + return getSelectClause(factory) + "\n" + getFromClause() + "\n" + getWhereClause() + "\n" + getGroupByClause() + "\n" + getPivotClause() + "\n"; - return sql; } @Override From 2244deb1fe15be0d259c402ec7ff761606ad5f03 Mon Sep 17 00:00:00 2001 From: Josh Eckels Date: Sun, 7 Jun 2026 09:18:30 -0700 Subject: [PATCH 02/17] Give more helpful feedback when remote connection fails (#7729) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### Rationale We give very opaque error feedback if an attempted ETL remote connection fails. #### Changes - Differentiate between IO and TLS problems - Log full exception #### Tasks 📍 - [x] Claude Code Review - ~Manual Testing~ - [x] Test Automation --- .../labkey/remoteapi/RemoteConnections.java | 105 ++- query/src/org/labkey/query/QueryModule.java | 852 +++++++++--------- .../query/controllers/QueryController.java | 15 +- .../view/testRemoteConnectionsFailure.jsp | 1 + 4 files changed, 545 insertions(+), 428 deletions(-) diff --git a/query/api-src/org/labkey/remoteapi/RemoteConnections.java b/query/api-src/org/labkey/remoteapi/RemoteConnections.java index 00683718a7e..fc76277bdbc 100644 --- a/query/api-src/org/labkey/remoteapi/RemoteConnections.java +++ b/query/api-src/org/labkey/remoteapi/RemoteConnections.java @@ -16,19 +16,29 @@ package org.labkey.remoteapi; import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.NotNull; +import org.junit.Assert; +import org.junit.Test; import org.labkey.api.action.LabKeyError; import org.labkey.api.data.Container; import org.labkey.api.data.PropertyManager; import org.labkey.api.data.PropertyManager.WritablePropertyMap; import org.labkey.api.security.ValidEmail; +import org.labkey.api.util.logging.LogHelper; import org.springframework.validation.BindException; +import javax.net.ssl.SSLException; import java.io.IOException; +import java.io.InputStream; import java.net.MalformedURLException; +import java.net.ServerSocket; +import java.net.Socket; import java.net.URL; import java.net.URLConnection; +import java.nio.charset.StandardCharsets; import java.util.Map; +import java.util.concurrent.TimeUnit; /** * User: gktaylor @@ -36,6 +46,8 @@ */ public class RemoteConnections { + private static final Logger LOG = LogHelper.getLogger(RemoteConnections.class, "Remote server connection management for ETLs"); + public static String REMOTE_QUERY_CONNECTIONS_CATEGORY = "remote-connections"; public static String REMOTE_FILE_CONNECTIONS_CATEGORY = "remote-file-connections"; public static String FIELD_URL = "URL"; @@ -91,9 +103,16 @@ public static boolean createOrEditRemoteConnection(RemoteConnectionForm remoteCo errors.addError(new LabKeyError("The entered URL is not valid.")); return false; } + catch (SSLException e) + { + LOG.warn("TLS error connecting to remote connection URL: {}", url, e); + errors.addError(new LabKeyError("A secure (TLS) connection to the entered URL could not be established. This is often caused by an untrusted, self-signed, or expired certificate. " + getBriefMessage(e))); + return false; + } catch (IOException e) { - errors.addError(new LabKeyError("A connection to the entered URL could not be established.")); + LOG.warn("Error connecting to remote connection URL: {}", url, e); + errors.addError(new LabKeyError("A connection to the entered URL could not be established. " + getBriefMessage(e))); return false; } @@ -139,6 +158,12 @@ public static boolean createOrEditRemoteConnection(RemoteConnectionForm remoteCo return true; } + /** @return a brief, user-facing description of the failure, suitable for appending to an error message. Full details should be logged separately. */ + public static String getBriefMessage(Throwable t) + { + return t.getMessage() == null ? t.getClass().getSimpleName() : t.getMessage(); + } + public static boolean deleteRemoteConnection(RemoteConnectionForm remoteConnectionForm, Container container) { String name = remoteConnectionForm.getConnectionName(); @@ -244,4 +269,82 @@ private static String makeRemoteConnectionKey(String connectionCategory, String { return connectionCategory + ":" + name; } + + public static class TestCase extends Assert + { + /** All URL validation failures return before touching the property store, so no container is needed */ + private BindException validate(String url) + { + RemoteConnectionForm form = new RemoteConnectionForm(); + form.setNewConnectionName("RemoteConnectionsTestCase"); + form.setUrl(url); + form.setUserEmail("remoteconnections_testcase@validation.test"); + form.setPassword("password"); + // A file connection doesn't require a folder path + form.setConnectionKind(CONNECTION_KIND_FILE); + + BindException errors = new BindException(form, "form"); + assertFalse("Expected validation to fail for URL: " + url, createOrEditRemoteConnection(form, null, errors)); + assertEquals("Expected a single validation error for URL: " + url, 1, errors.getErrorCount()); + return errors; + } + + private void assertErrorStartsWith(BindException errors, String expectedPrefix) + { + String message = errors.getAllErrors().get(0).getDefaultMessage(); + assertNotNull("Expected an error message", message); + assertTrue("Expected error message to start with '" + expectedPrefix + "' but was: " + message, message.startsWith(expectedPrefix)); + } + + @Test + public void testMalformedUrl() + { + assertErrorStartsWith(validate("hptt://localhost/bogus"), "The entered URL is not valid."); + } + + @Test + public void testConnectionRefused() throws IOException + { + // Bind an ephemeral port, then release it so nothing is listening when we connect + int port; + try (ServerSocket socket = new ServerSocket(0)) + { + port = socket.getLocalPort(); + } + assertErrorStartsWith(validate("http://localhost:" + port + "/"), "A connection to the entered URL could not be established."); + } + + @Test + public void testTlsFailure() throws Exception + { + // Answer the TLS handshake with plain text, which fails the https client connection with an SSLException + try (ServerSocket socket = new ServerSocket(0)) + { + Thread responder = new Thread(() -> + { + try (Socket client = socket.accept()) + { + client.getOutputStream().write("This is not a TLS handshake".getBytes(StandardCharsets.UTF_8)); + client.getOutputStream().flush(); + // Drain the client's handshake bytes until it disconnects + InputStream in = client.getInputStream(); + byte[] buffer = new byte[1024]; + while (in.read(buffer) != -1) { /* keep draining */ } + } + catch (IOException ignored) {} + }, "RemoteConnections.TestCase non-TLS responder"); + responder.start(); + + assertErrorStartsWith(validate("https://localhost:" + socket.getLocalPort() + "/"), "A secure (TLS) connection to the entered URL could not be established."); + responder.join(TimeUnit.SECONDS.toMillis(10)); + } + } + + @Test + public void testGetBriefMessage() + { + assertEquals("boom", getBriefMessage(new IOException("boom"))); + assertEquals("IOException", getBriefMessage(new IOException())); + } + } } diff --git a/query/src/org/labkey/query/QueryModule.java b/query/src/org/labkey/query/QueryModule.java index 2e0e4976567..ada49cf15ba 100644 --- a/query/src/org/labkey/query/QueryModule.java +++ b/query/src/org/labkey/query/QueryModule.java @@ -1,425 +1,427 @@ -/* - * Copyright (c) 2008-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.labkey.query; - -import org.jetbrains.annotations.NotNull; -import org.json.JSONObject; -import org.labkey.api.admin.FolderSerializationRegistry; -import org.labkey.api.audit.AuditLogService; -import org.labkey.api.audit.DefaultAuditProvider; -import org.labkey.api.cache.CacheManager; -import org.labkey.api.data.Aggregate; -import org.labkey.api.data.Container; -import org.labkey.api.data.ContainerManager; -import org.labkey.api.data.JdbcType; -import org.labkey.api.data.views.DataViewService; -import org.labkey.api.exp.property.PropertyService; -import org.labkey.api.message.digest.DailyMessageDigest; -import org.labkey.api.message.digest.ReportAndDatasetChangeDigestProvider; -import org.labkey.api.module.AdminLinkManager; -import org.labkey.api.module.DefaultModule; -import org.labkey.api.module.Module; -import org.labkey.api.module.ModuleContext; -import org.labkey.api.pipeline.PipelineService; -import org.labkey.api.query.DefaultSchema; -import org.labkey.api.query.JavaExportScriptFactory; -import org.labkey.api.query.JavaScriptExportScriptFactory; -import org.labkey.api.query.PerlExportScriptFactory; -import org.labkey.api.query.PythonExportScriptFactory; -import org.labkey.api.query.QuerySchema; -import org.labkey.api.query.QueryService; -import org.labkey.api.query.QueryView; -import org.labkey.api.query.RExportScriptFactory; -import org.labkey.api.query.SasExportScriptFactory; -import org.labkey.api.query.SimpleTableDomainKind; -import org.labkey.api.query.URLExportScriptFactory; -import org.labkey.api.query.column.BuiltInColumnTypes; -import org.labkey.api.query.snapshot.QuerySnapshotService; -import org.labkey.api.reports.ReportService; -import org.labkey.api.reports.report.ExternalScriptEngineReport; -import org.labkey.api.reports.report.InternalScriptEngineReport; -import org.labkey.api.reports.report.JavaScriptReport; -import org.labkey.api.reports.report.JavaScriptReportDescriptor; -import org.labkey.api.reports.report.QueryReport; -import org.labkey.api.reports.report.QueryReportDescriptor; -import org.labkey.api.reports.report.ReportDescriptor; -import org.labkey.api.reports.report.ReportUrls; -import org.labkey.api.reports.report.python.IpynbReport; -import org.labkey.api.reports.report.python.IpynbReportDescriptor; -import org.labkey.api.reports.report.r.RReport; -import org.labkey.api.reports.report.r.RReportDescriptor; -import org.labkey.api.search.SearchService; -import org.labkey.api.security.User; -import org.labkey.api.security.permissions.AdminPermission; -import org.labkey.api.security.permissions.PlatformDeveloperPermission; -import org.labkey.api.security.permissions.ReadPermission; -import org.labkey.api.security.roles.PlatformDeveloperRole; -import org.labkey.api.security.roles.Role; -import org.labkey.api.security.roles.RoleManager; -import org.labkey.api.settings.AdminConsole; -import org.labkey.api.stats.AnalyticsProviderRegistry; -import org.labkey.api.stats.SummaryStatisticRegistry; -import org.labkey.api.util.JspTestCase; -import org.labkey.api.util.JunitUtil; -import org.labkey.api.util.PageFlowUtil; -import org.labkey.api.util.emailTemplate.EmailTemplateService; -import org.labkey.api.view.ActionURL; -import org.labkey.api.view.NavTree; -import org.labkey.api.view.WebPartFactory; -import org.labkey.api.writer.ContainerUser; -import org.labkey.query.analytics.AggregatesCountNonBlankAnalyticsProvider; -import org.labkey.query.analytics.AggregatesMaxAnalyticsProvider; -import org.labkey.query.analytics.AggregatesMeanAnalyticsProvider; -import org.labkey.query.analytics.AggregatesMinAnalyticsProvider; -import org.labkey.query.analytics.AggregatesSumAnalyticsProvider; -import org.labkey.query.analytics.RemoveColumnAnalyticsProvider; -import org.labkey.query.analytics.SummaryStatisticsAnalyticsProvider; -import org.labkey.query.audit.QueryExportAuditProvider; -import org.labkey.query.audit.QueryUpdateAuditProvider; -import org.labkey.query.controllers.OlapController; -import org.labkey.query.controllers.QueryController; -import org.labkey.query.controllers.SqlController; -import org.labkey.query.jdbc.QueryDriver; -import org.labkey.query.olap.MemberSet; -import org.labkey.query.olap.ServerManager; -import org.labkey.query.olap.metadata.MetadataElementBase; -import org.labkey.query.olap.rolap.RolapReader; -import org.labkey.query.olap.rolap.RolapTestCase; -import org.labkey.query.olap.rolap.RolapTestSchema; -import org.labkey.query.persist.QueryManager; -import org.labkey.query.reports.AttachmentReport; -import org.labkey.query.reports.LinkReport; -import org.labkey.query.reports.ModuleReportCache; -import org.labkey.query.reports.ReportAndDatasetChangeDigestProviderImpl; -import org.labkey.query.reports.ReportAuditProvider; -import org.labkey.query.reports.ReportImporter; -import org.labkey.query.reports.ReportNotificationInfoProvider; -import org.labkey.query.reports.ReportServiceImpl; -import org.labkey.query.reports.ReportViewProvider; -import org.labkey.query.reports.ReportWriter; -import org.labkey.query.reports.ReportsController; -import org.labkey.query.reports.ReportsPipelineProvider; -import org.labkey.query.reports.ReportsWebPartFactory; -import org.labkey.query.reports.ViewCategoryImporter; -import org.labkey.query.reports.ViewCategoryWriter; -import org.labkey.query.reports.getdata.AggregateQueryDataTransform; -import org.labkey.query.reports.getdata.FilterClauseBuilder; -import org.labkey.query.reports.view.ReportAndDatasetChangeDigestEmailTemplate; -import org.labkey.query.reports.view.ReportUIProvider; -import org.labkey.query.sql.Method; -import org.labkey.query.sql.QNode; -import org.labkey.query.sql.Query; -import org.labkey.query.sql.SqlParser; -import org.labkey.query.view.InheritedQueryDataViewProvider; -import org.labkey.query.view.QueryDataViewProvider; -import org.labkey.query.view.QueryWebPartFactory; -import org.labkey.remoteapi.SelectRowsStreamHack; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; -import java.util.Set; -import java.util.function.Supplier; - -import static org.labkey.api.query.QueryService.USE_ROW_BY_ROW_UPDATE; - -public class QueryModule extends DefaultModule -{ - public QueryModule() - { - QueryService.setInstance(new QueryServiceImpl()); - BuiltInColumnTypes.registerStandardColumnTransformers(); - - QueryDriver.register(); - ReportAndDatasetChangeDigestProvider.set(new ReportAndDatasetChangeDigestProviderImpl()); - } - - @Override - public String getName() - { - return "Query"; - } - - @Override - public Double getSchemaVersion() - { - return 25.000; - } - - @Override - protected void init() - { - DefaultSchema.registerProvider("rolap_test", new DefaultSchema.SchemaProvider(this) - { - @Override - public boolean isAvailable(DefaultSchema schema, Module module) - { - return schema.getContainer().getParsedPath().equals(JunitUtil.getTestContainerPath()); - } - - @Override - public QuerySchema createSchema(DefaultSchema schema, Module module) - { - return new RolapTestSchema(schema.getUser(), schema.getContainer()); - } - }); - - addController("query", QueryController.class); - addController("sql", SqlController.class); - addController("reports", ReportsController.class); - addController("olap", OlapController.class); - - ExternalSchema.register(); - LinkedSchema.register(); - - QueryService.get().addQueryListener(new CustomViewQueryChangeListener()); - QueryService.get().addQueryListener(new QuerySnapshotQueryChangeListener()); - QueryService.get().addQueryListener(new QueryDefQueryChangeListener()); - - ReportService.registerProvider(ReportServiceImpl.getInstance()); - ReportService.get().addUIProvider(new ReportUIProvider()); - ReportService.get().addGlobalItemFilterType(JavaScriptReport.TYPE); - ReportService.get().addGlobalItemFilterType(QuerySnapshotService.TYPE); - ReportService.get().addGlobalItemFilterType(IpynbReport.TYPE); - - ReportService.get().registerDescriptor(new IpynbReportDescriptor()); - ReportService.get().registerDescriptor(new ReportDescriptor()); - ReportService.get().registerDescriptor(new QueryReportDescriptor()); - ReportService.get().registerDescriptor(new RReportDescriptor()); - ReportService.get().registerDescriptor(new JavaScriptReportDescriptor()); - - ReportService.get().registerReport(new IpynbReport()); - ReportService.get().registerReport(new QueryReport()); - ReportService.get().registerReport(new RReport()); - ReportService.get().registerReport(new ExternalScriptEngineReport()); - ReportService.get().registerReport(new InternalScriptEngineReport()); - ReportService.get().registerReport(new JavaScriptReport()); - ReportService.get().registerReport(new AttachmentReport()); - ReportService.get().registerReport(new LinkReport()); - EmailTemplateService.get().registerTemplate(ReportAndDatasetChangeDigestEmailTemplate.class); - - QueryView.register(new RExportScriptFactory()); - QueryView.register(new JavaScriptExportScriptFactory()); - QueryView.register(new PerlExportScriptFactory()); - QueryView.register(new JavaExportScriptFactory()); - QueryView.register(new URLExportScriptFactory()); - QueryView.register(new PythonExportScriptFactory()); - QueryView.register(new SasExportScriptFactory()); - - DataViewService.get().registerProvider(ReportViewProvider.TYPE, new ReportViewProvider()); - - DataViewService.get().registerProvider(QueryDataViewProvider.TYPE, new QueryDataViewProvider()); - DataViewService.get().registerProvider(InheritedQueryDataViewProvider.TYPE, new InheritedQueryDataViewProvider()); - - AdminConsole.addExperimentalFeatureFlag(QueryView.EXPERIMENTAL_GENERIC_DETAILS_URL, "Generic [details] link in grids/queries", - "This feature will turn on generating a generic [details] URL link in most grids.", false); - AdminConsole.addExperimentalFeatureFlag(QueryServiceImpl.EXPERIMENTAL_LAST_MODIFIED, "Include Last-Modified header on query metadata requests", - "For schema, query, and view metadata requests include a Last-Modified header such that the browser can cache the response. " + - "The metadata is invalidated when performing actions such as creating a new List or modifying the columns on a custom view", false); - AdminConsole.addExperimentalFeatureFlag(USE_ROW_BY_ROW_UPDATE, "Use row-by-row update", "For Query.updateRows api, do row-by-row update, instead of using a prepared statement that updates rows in batches.", false); - AdminConsole.addExperimentalFeatureFlag(QueryServiceImpl.EXPERIMENTAL_PRODUCT_ALL_FOLDER_LOOKUPS, "Less restrictive product folder lookups", - "Allow for lookup fields in product folders to query across all folders within the top-level folder.", false); - AdminConsole.addExperimentalFeatureFlag(QueryServiceImpl.EXPERIMENTAL_PRODUCT_PROJECT_DATA_LISTING_SCOPED, "Product folders display folder-specific data", - "Only list folder-specific data within product folders.", false); - } - - - @Override - @NotNull - protected Collection createWebPartFactories() - { - return List.of( - new DataViewsWebPartFactory(), - new QueryWebPartFactory(), - new ReportsWebPartFactory() -// new QueryBrowserWebPartFactory() - ); - } - - @Override - public boolean hasScripts() - { - return true; - } - - @Override - public void doStartup(ModuleContext moduleContext) - { - ContainerManager.addContainerListener(QueryManager.CONTAINER_LISTENER, ContainerManager.ContainerListener.Order.Last); - - if (null != PipelineService.get()) - PipelineService.get().registerPipelineProvider(new ReportsPipelineProvider(this)); - QueryController.registerAdminConsoleLinks(); - - FolderSerializationRegistry folderRegistry = FolderSerializationRegistry.get(); - if (null != folderRegistry) - { - folderRegistry.addFactories(new QueryWriter.Factory(), new QueryImporter.Factory()); - folderRegistry.addFactories(new CustomViewWriter.Factory(), new CustomViewImporter.Factory()); - folderRegistry.addFactories(new ReportWriter.Factory(), new ReportImporter.Factory()); - folderRegistry.addFactories(new ViewCategoryWriter.Factory(), new ViewCategoryImporter.Factory()); - folderRegistry.addFactories(new ExternalSchemaDefWriterFactory(), new ExternalSchemaDefImporterFactory()); - } - - SearchService ss = SearchService.get(); - - if (null != ss) - { - ss.addDocumentProvider(ExternalSchemaDocumentProvider.getInstance()); - ss.addSearchCategory(ExternalSchemaDocumentProvider.externalTableCategory); - } - if (null != PropertyService.get()) - PropertyService.get().registerDomainKind(new SimpleTableDomainKind()); - - if (null != AuditLogService.get() && AuditLogService.get().getClass() != DefaultAuditProvider.class) - { - AuditLogService.get().registerAuditType(new QueryExportAuditProvider()); - AuditLogService.get().registerAuditType(new QueryUpdateAuditProvider()); - } - AuditLogService.get().registerAuditType(new ReportAuditProvider()); - - ReportAndDatasetChangeDigestProvider.get().addNotificationInfoProvider(new ReportNotificationInfoProvider()); - DailyMessageDigest.getInstance().addProvider(ReportAndDatasetChangeDigestProvider.get()); - // Note: DailyMessageDigest timer is initialized by the AnnouncementModule - - CacheManager.addListener(new ServerManager.CacheListener()); - CacheManager.addListener(new QueryServiceImpl.CacheListener()); - - AdminLinkManager.getInstance().addListener((adminNavTree, container, user) -> { - if (container.hasPermission(user, ReadPermission.class)) - adminNavTree.addChild(new NavTree("Manage Views", PageFlowUtil.urlProvider(ReportUrls.class).urlManageViews(container))); - }); - - AnalyticsProviderRegistry analyticsProviderRegistry = AnalyticsProviderRegistry.get(); - if (null != analyticsProviderRegistry) - { - analyticsProviderRegistry.registerProvider(new AggregatesCountNonBlankAnalyticsProvider()); - analyticsProviderRegistry.registerProvider(new AggregatesSumAnalyticsProvider()); - analyticsProviderRegistry.registerProvider(new AggregatesMeanAnalyticsProvider()); - analyticsProviderRegistry.registerProvider(new AggregatesMinAnalyticsProvider()); - analyticsProviderRegistry.registerProvider(new AggregatesMaxAnalyticsProvider()); - analyticsProviderRegistry.registerProvider(new SummaryStatisticsAnalyticsProvider()); - analyticsProviderRegistry.registerProvider(new RemoveColumnAnalyticsProvider()); - } - - SummaryStatisticRegistry summaryStatisticRegistry = SummaryStatisticRegistry.get(); - if (null != summaryStatisticRegistry) - { - summaryStatisticRegistry.register(Aggregate.BaseType.SUM); - summaryStatisticRegistry.register(Aggregate.BaseType.MEAN); - summaryStatisticRegistry.register(Aggregate.BaseType.COUNT); - summaryStatisticRegistry.register(Aggregate.BaseType.MIN); - summaryStatisticRegistry.register(Aggregate.BaseType.MAX); - } - - QueryManager.registerUsageMetrics(getName()); - ReportServiceImpl.registerUsageMetrics(getName()); - - // Administrators, Platform Developers, and Trusted Analysts can edit queries, if they also have edit permissions in the current folder - RoleManager.registerPermission(new EditQueriesPermission()); - Role platformDeveloperRole = RoleManager.getRole(PlatformDeveloperRole.class); - platformDeveloperRole.addPermission(EditQueriesPermission.class); - Role trustedAnalystRole = RoleManager.getRole("org.labkey.api.security.roles.TrustedAnalystRole"); - if (null != trustedAnalystRole) - trustedAnalystRole.addPermission(EditQueriesPermission.class); - } - - @Override - @NotNull - public Set getSchemaNames() - { - return PageFlowUtil.set(QueryManager.get().getDbSchemaName(), "junit"); - } - - @Override - @NotNull - public Set getIntegrationTests() - { - return Set.of( - ModuleReportCache.TestCase.class, - OlapController.TestCase.class, - QueryController.TestCase.class, - QueryController.SaveRowsTestCase.class, - QueryServiceImpl.TestCase.class, - RolapReader.RolapTest.class, - RolapTestCase.class, - ServerManager.TestCase.class, - SelectRowsStreamHack.TestCase.class - ); - } - - @Override - public @NotNull Collection>> getIntegrationTestFactories() - { - List>> ret = new ArrayList<>(super.getIntegrationTestFactories()); - ret.add(new JspTestCase("/org/labkey/query/MultiValueTest.jsp")); - ret.add(new JspTestCase("/org/labkey/query/olap/OlapTestCase.jsp")); - ret.add(new JspTestCase("/org/labkey/query/QueryServiceImplTestCase.jsp")); - ret.add(new JspTestCase("/org/labkey/query/QueryTestCase.jsp")); - ret.add(new JspTestCase("/org/labkey/query/sql/CalculatedColumnTestCase.jsp")); - - return ret; - } - - - @Override - @NotNull - public Set getUnitTests() - { - return Set.of( - AggregateQueryDataTransform.TestCase.class, - AttachmentReport.TestCase.class, - FilterClauseBuilder.TestCase.class, - JdbcType.TestCase.class, - MemberSet.TestCase.class, - MetadataElementBase.TestCase.class, - Method.TestCase.class, - QNode.TestCase.class, - Query.TestCase.class, - ReportsController.SerializationTest.class, - SqlParser.SqlParserTestCase.class, - TableWriter.TestCase.class - ); - } - - @Override - public ActionURL getTabURL(Container c, User user) - { - // Don't show Query nav trails to users who aren't admins or developers since they almost certainly don't want - // to go to those links - if (c.hasOneOf(user, AdminPermission.class, PlatformDeveloperPermission.class)) - { - return super.getTabURL(c, user); - } - return null; - } - - @Override - public JSONObject getPageContextJson(ContainerUser context) - { - JSONObject json = super.getPageContextJson(context); - boolean hasEditQueriesPermission = context.getContainer().hasPermission(context.getUser(), EditQueriesPermission.class); - json.put("hasEditQueriesPermission", hasEditQueriesPermission); - Container container = context.getContainer(); - boolean isProductFoldersEnabled = container != null && container.isProductFoldersEnabled(); // TODO: should these be moved to CoreModule? - json.put(QueryService.PRODUCT_FOLDERS_ENABLED, isProductFoldersEnabled); - json.put(QueryService.PRODUCT_FOLDERS_EXIST, isProductFoldersEnabled && container.hasProductFolders()); - json.put(QueryService.EXPERIMENTAL_PRODUCT_ALL_FOLDER_LOOKUPS, QueryService.get().isProductFoldersAllFolderScopeEnabled()); - json.put(QueryService.EXPERIMENTAL_PRODUCT_PROJECT_DATA_LISTING_SCOPED, QueryService.get().isProductFoldersDataListingScopedToProject()); - return json; - } -} +/* + * Copyright (c) 2008-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.labkey.query; + +import org.jetbrains.annotations.NotNull; +import org.json.JSONObject; +import org.labkey.api.admin.FolderSerializationRegistry; +import org.labkey.api.audit.AuditLogService; +import org.labkey.api.audit.DefaultAuditProvider; +import org.labkey.api.cache.CacheManager; +import org.labkey.api.data.Aggregate; +import org.labkey.api.data.Container; +import org.labkey.api.data.ContainerManager; +import org.labkey.api.data.JdbcType; +import org.labkey.api.data.views.DataViewService; +import org.labkey.api.exp.property.PropertyService; +import org.labkey.api.message.digest.DailyMessageDigest; +import org.labkey.api.message.digest.ReportAndDatasetChangeDigestProvider; +import org.labkey.api.module.AdminLinkManager; +import org.labkey.api.module.DefaultModule; +import org.labkey.api.module.Module; +import org.labkey.api.module.ModuleContext; +import org.labkey.api.pipeline.PipelineService; +import org.labkey.api.query.DefaultSchema; +import org.labkey.api.query.JavaExportScriptFactory; +import org.labkey.api.query.JavaScriptExportScriptFactory; +import org.labkey.api.query.PerlExportScriptFactory; +import org.labkey.api.query.PythonExportScriptFactory; +import org.labkey.api.query.QuerySchema; +import org.labkey.api.query.QueryService; +import org.labkey.api.query.QueryView; +import org.labkey.api.query.RExportScriptFactory; +import org.labkey.api.query.SasExportScriptFactory; +import org.labkey.api.query.SimpleTableDomainKind; +import org.labkey.api.query.URLExportScriptFactory; +import org.labkey.api.query.column.BuiltInColumnTypes; +import org.labkey.api.query.snapshot.QuerySnapshotService; +import org.labkey.api.reports.ReportService; +import org.labkey.api.reports.report.ExternalScriptEngineReport; +import org.labkey.api.reports.report.InternalScriptEngineReport; +import org.labkey.api.reports.report.JavaScriptReport; +import org.labkey.api.reports.report.JavaScriptReportDescriptor; +import org.labkey.api.reports.report.QueryReport; +import org.labkey.api.reports.report.QueryReportDescriptor; +import org.labkey.api.reports.report.ReportDescriptor; +import org.labkey.api.reports.report.ReportUrls; +import org.labkey.api.reports.report.python.IpynbReport; +import org.labkey.api.reports.report.python.IpynbReportDescriptor; +import org.labkey.api.reports.report.r.RReport; +import org.labkey.api.reports.report.r.RReportDescriptor; +import org.labkey.api.search.SearchService; +import org.labkey.api.security.User; +import org.labkey.api.security.permissions.AdminPermission; +import org.labkey.api.security.permissions.PlatformDeveloperPermission; +import org.labkey.api.security.permissions.ReadPermission; +import org.labkey.api.security.roles.PlatformDeveloperRole; +import org.labkey.api.security.roles.Role; +import org.labkey.api.security.roles.RoleManager; +import org.labkey.api.settings.AdminConsole; +import org.labkey.api.stats.AnalyticsProviderRegistry; +import org.labkey.api.stats.SummaryStatisticRegistry; +import org.labkey.api.util.JspTestCase; +import org.labkey.api.util.JunitUtil; +import org.labkey.api.util.PageFlowUtil; +import org.labkey.api.util.emailTemplate.EmailTemplateService; +import org.labkey.api.view.ActionURL; +import org.labkey.api.view.NavTree; +import org.labkey.api.view.WebPartFactory; +import org.labkey.api.writer.ContainerUser; +import org.labkey.query.analytics.AggregatesCountNonBlankAnalyticsProvider; +import org.labkey.query.analytics.AggregatesMaxAnalyticsProvider; +import org.labkey.query.analytics.AggregatesMeanAnalyticsProvider; +import org.labkey.query.analytics.AggregatesMinAnalyticsProvider; +import org.labkey.query.analytics.AggregatesSumAnalyticsProvider; +import org.labkey.query.analytics.RemoveColumnAnalyticsProvider; +import org.labkey.query.analytics.SummaryStatisticsAnalyticsProvider; +import org.labkey.query.audit.QueryExportAuditProvider; +import org.labkey.query.audit.QueryUpdateAuditProvider; +import org.labkey.query.controllers.OlapController; +import org.labkey.query.controllers.QueryController; +import org.labkey.query.controllers.SqlController; +import org.labkey.query.jdbc.QueryDriver; +import org.labkey.query.olap.MemberSet; +import org.labkey.query.olap.ServerManager; +import org.labkey.query.olap.metadata.MetadataElementBase; +import org.labkey.query.olap.rolap.RolapReader; +import org.labkey.query.olap.rolap.RolapTestCase; +import org.labkey.query.olap.rolap.RolapTestSchema; +import org.labkey.query.persist.QueryManager; +import org.labkey.query.reports.AttachmentReport; +import org.labkey.query.reports.LinkReport; +import org.labkey.query.reports.ModuleReportCache; +import org.labkey.query.reports.ReportAndDatasetChangeDigestProviderImpl; +import org.labkey.query.reports.ReportAuditProvider; +import org.labkey.query.reports.ReportImporter; +import org.labkey.query.reports.ReportNotificationInfoProvider; +import org.labkey.query.reports.ReportServiceImpl; +import org.labkey.query.reports.ReportViewProvider; +import org.labkey.query.reports.ReportWriter; +import org.labkey.query.reports.ReportsController; +import org.labkey.query.reports.ReportsPipelineProvider; +import org.labkey.query.reports.ReportsWebPartFactory; +import org.labkey.query.reports.ViewCategoryImporter; +import org.labkey.query.reports.ViewCategoryWriter; +import org.labkey.query.reports.getdata.AggregateQueryDataTransform; +import org.labkey.query.reports.getdata.FilterClauseBuilder; +import org.labkey.query.reports.view.ReportAndDatasetChangeDigestEmailTemplate; +import org.labkey.query.reports.view.ReportUIProvider; +import org.labkey.query.sql.Method; +import org.labkey.query.sql.QNode; +import org.labkey.query.sql.Query; +import org.labkey.query.sql.SqlParser; +import org.labkey.query.view.InheritedQueryDataViewProvider; +import org.labkey.query.view.QueryDataViewProvider; +import org.labkey.query.view.QueryWebPartFactory; +import org.labkey.remoteapi.RemoteConnections; +import org.labkey.remoteapi.SelectRowsStreamHack; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Set; +import java.util.function.Supplier; + +import static org.labkey.api.query.QueryService.USE_ROW_BY_ROW_UPDATE; + +public class QueryModule extends DefaultModule +{ + public QueryModule() + { + QueryService.setInstance(new QueryServiceImpl()); + BuiltInColumnTypes.registerStandardColumnTransformers(); + + QueryDriver.register(); + ReportAndDatasetChangeDigestProvider.set(new ReportAndDatasetChangeDigestProviderImpl()); + } + + @Override + public String getName() + { + return "Query"; + } + + @Override + public Double getSchemaVersion() + { + return 25.000; + } + + @Override + protected void init() + { + DefaultSchema.registerProvider("rolap_test", new DefaultSchema.SchemaProvider(this) + { + @Override + public boolean isAvailable(DefaultSchema schema, Module module) + { + return schema.getContainer().getParsedPath().equals(JunitUtil.getTestContainerPath()); + } + + @Override + public QuerySchema createSchema(DefaultSchema schema, Module module) + { + return new RolapTestSchema(schema.getUser(), schema.getContainer()); + } + }); + + addController("query", QueryController.class); + addController("sql", SqlController.class); + addController("reports", ReportsController.class); + addController("olap", OlapController.class); + + ExternalSchema.register(); + LinkedSchema.register(); + + QueryService.get().addQueryListener(new CustomViewQueryChangeListener()); + QueryService.get().addQueryListener(new QuerySnapshotQueryChangeListener()); + QueryService.get().addQueryListener(new QueryDefQueryChangeListener()); + + ReportService.registerProvider(ReportServiceImpl.getInstance()); + ReportService.get().addUIProvider(new ReportUIProvider()); + ReportService.get().addGlobalItemFilterType(JavaScriptReport.TYPE); + ReportService.get().addGlobalItemFilterType(QuerySnapshotService.TYPE); + ReportService.get().addGlobalItemFilterType(IpynbReport.TYPE); + + ReportService.get().registerDescriptor(new IpynbReportDescriptor()); + ReportService.get().registerDescriptor(new ReportDescriptor()); + ReportService.get().registerDescriptor(new QueryReportDescriptor()); + ReportService.get().registerDescriptor(new RReportDescriptor()); + ReportService.get().registerDescriptor(new JavaScriptReportDescriptor()); + + ReportService.get().registerReport(new IpynbReport()); + ReportService.get().registerReport(new QueryReport()); + ReportService.get().registerReport(new RReport()); + ReportService.get().registerReport(new ExternalScriptEngineReport()); + ReportService.get().registerReport(new InternalScriptEngineReport()); + ReportService.get().registerReport(new JavaScriptReport()); + ReportService.get().registerReport(new AttachmentReport()); + ReportService.get().registerReport(new LinkReport()); + EmailTemplateService.get().registerTemplate(ReportAndDatasetChangeDigestEmailTemplate.class); + + QueryView.register(new RExportScriptFactory()); + QueryView.register(new JavaScriptExportScriptFactory()); + QueryView.register(new PerlExportScriptFactory()); + QueryView.register(new JavaExportScriptFactory()); + QueryView.register(new URLExportScriptFactory()); + QueryView.register(new PythonExportScriptFactory()); + QueryView.register(new SasExportScriptFactory()); + + DataViewService.get().registerProvider(ReportViewProvider.TYPE, new ReportViewProvider()); + + DataViewService.get().registerProvider(QueryDataViewProvider.TYPE, new QueryDataViewProvider()); + DataViewService.get().registerProvider(InheritedQueryDataViewProvider.TYPE, new InheritedQueryDataViewProvider()); + + AdminConsole.addExperimentalFeatureFlag(QueryView.EXPERIMENTAL_GENERIC_DETAILS_URL, "Generic [details] link in grids/queries", + "This feature will turn on generating a generic [details] URL link in most grids.", false); + AdminConsole.addExperimentalFeatureFlag(QueryServiceImpl.EXPERIMENTAL_LAST_MODIFIED, "Include Last-Modified header on query metadata requests", + "For schema, query, and view metadata requests include a Last-Modified header such that the browser can cache the response. " + + "The metadata is invalidated when performing actions such as creating a new List or modifying the columns on a custom view", false); + AdminConsole.addExperimentalFeatureFlag(USE_ROW_BY_ROW_UPDATE, "Use row-by-row update", "For Query.updateRows api, do row-by-row update, instead of using a prepared statement that updates rows in batches.", false); + AdminConsole.addExperimentalFeatureFlag(QueryServiceImpl.EXPERIMENTAL_PRODUCT_ALL_FOLDER_LOOKUPS, "Less restrictive product folder lookups", + "Allow for lookup fields in product folders to query across all folders within the top-level folder.", false); + AdminConsole.addExperimentalFeatureFlag(QueryServiceImpl.EXPERIMENTAL_PRODUCT_PROJECT_DATA_LISTING_SCOPED, "Product folders display folder-specific data", + "Only list folder-specific data within product folders.", false); + } + + + @Override + @NotNull + protected Collection createWebPartFactories() + { + return List.of( + new DataViewsWebPartFactory(), + new QueryWebPartFactory(), + new ReportsWebPartFactory() +// new QueryBrowserWebPartFactory() + ); + } + + @Override + public boolean hasScripts() + { + return true; + } + + @Override + public void doStartup(ModuleContext moduleContext) + { + ContainerManager.addContainerListener(QueryManager.CONTAINER_LISTENER, ContainerManager.ContainerListener.Order.Last); + + if (null != PipelineService.get()) + PipelineService.get().registerPipelineProvider(new ReportsPipelineProvider(this)); + QueryController.registerAdminConsoleLinks(); + + FolderSerializationRegistry folderRegistry = FolderSerializationRegistry.get(); + if (null != folderRegistry) + { + folderRegistry.addFactories(new QueryWriter.Factory(), new QueryImporter.Factory()); + folderRegistry.addFactories(new CustomViewWriter.Factory(), new CustomViewImporter.Factory()); + folderRegistry.addFactories(new ReportWriter.Factory(), new ReportImporter.Factory()); + folderRegistry.addFactories(new ViewCategoryWriter.Factory(), new ViewCategoryImporter.Factory()); + folderRegistry.addFactories(new ExternalSchemaDefWriterFactory(), new ExternalSchemaDefImporterFactory()); + } + + SearchService ss = SearchService.get(); + + if (null != ss) + { + ss.addDocumentProvider(ExternalSchemaDocumentProvider.getInstance()); + ss.addSearchCategory(ExternalSchemaDocumentProvider.externalTableCategory); + } + if (null != PropertyService.get()) + PropertyService.get().registerDomainKind(new SimpleTableDomainKind()); + + if (null != AuditLogService.get() && AuditLogService.get().getClass() != DefaultAuditProvider.class) + { + AuditLogService.get().registerAuditType(new QueryExportAuditProvider()); + AuditLogService.get().registerAuditType(new QueryUpdateAuditProvider()); + } + AuditLogService.get().registerAuditType(new ReportAuditProvider()); + + ReportAndDatasetChangeDigestProvider.get().addNotificationInfoProvider(new ReportNotificationInfoProvider()); + DailyMessageDigest.getInstance().addProvider(ReportAndDatasetChangeDigestProvider.get()); + // Note: DailyMessageDigest timer is initialized by the AnnouncementModule + + CacheManager.addListener(new ServerManager.CacheListener()); + CacheManager.addListener(new QueryServiceImpl.CacheListener()); + + AdminLinkManager.getInstance().addListener((adminNavTree, container, user) -> { + if (container.hasPermission(user, ReadPermission.class)) + adminNavTree.addChild(new NavTree("Manage Views", PageFlowUtil.urlProvider(ReportUrls.class).urlManageViews(container))); + }); + + AnalyticsProviderRegistry analyticsProviderRegistry = AnalyticsProviderRegistry.get(); + if (null != analyticsProviderRegistry) + { + analyticsProviderRegistry.registerProvider(new AggregatesCountNonBlankAnalyticsProvider()); + analyticsProviderRegistry.registerProvider(new AggregatesSumAnalyticsProvider()); + analyticsProviderRegistry.registerProvider(new AggregatesMeanAnalyticsProvider()); + analyticsProviderRegistry.registerProvider(new AggregatesMinAnalyticsProvider()); + analyticsProviderRegistry.registerProvider(new AggregatesMaxAnalyticsProvider()); + analyticsProviderRegistry.registerProvider(new SummaryStatisticsAnalyticsProvider()); + analyticsProviderRegistry.registerProvider(new RemoveColumnAnalyticsProvider()); + } + + SummaryStatisticRegistry summaryStatisticRegistry = SummaryStatisticRegistry.get(); + if (null != summaryStatisticRegistry) + { + summaryStatisticRegistry.register(Aggregate.BaseType.SUM); + summaryStatisticRegistry.register(Aggregate.BaseType.MEAN); + summaryStatisticRegistry.register(Aggregate.BaseType.COUNT); + summaryStatisticRegistry.register(Aggregate.BaseType.MIN); + summaryStatisticRegistry.register(Aggregate.BaseType.MAX); + } + + QueryManager.registerUsageMetrics(getName()); + ReportServiceImpl.registerUsageMetrics(getName()); + + // Administrators, Platform Developers, and Trusted Analysts can edit queries, if they also have edit permissions in the current folder + RoleManager.registerPermission(new EditQueriesPermission()); + Role platformDeveloperRole = RoleManager.getRole(PlatformDeveloperRole.class); + platformDeveloperRole.addPermission(EditQueriesPermission.class); + Role trustedAnalystRole = RoleManager.getRole("org.labkey.api.security.roles.TrustedAnalystRole"); + if (null != trustedAnalystRole) + trustedAnalystRole.addPermission(EditQueriesPermission.class); + } + + @Override + @NotNull + public Set getSchemaNames() + { + return PageFlowUtil.set(QueryManager.get().getDbSchemaName(), "junit"); + } + + @Override + @NotNull + public Set getIntegrationTests() + { + return Set.of( + ModuleReportCache.TestCase.class, + OlapController.TestCase.class, + QueryController.TestCase.class, + QueryController.SaveRowsTestCase.class, + QueryServiceImpl.TestCase.class, + RolapReader.RolapTest.class, + RolapTestCase.class, + ServerManager.TestCase.class, + SelectRowsStreamHack.TestCase.class + ); + } + + @Override + public @NotNull Collection>> getIntegrationTestFactories() + { + List>> ret = new ArrayList<>(super.getIntegrationTestFactories()); + ret.add(new JspTestCase("/org/labkey/query/MultiValueTest.jsp")); + ret.add(new JspTestCase("/org/labkey/query/olap/OlapTestCase.jsp")); + ret.add(new JspTestCase("/org/labkey/query/QueryServiceImplTestCase.jsp")); + ret.add(new JspTestCase("/org/labkey/query/QueryTestCase.jsp")); + ret.add(new JspTestCase("/org/labkey/query/sql/CalculatedColumnTestCase.jsp")); + + return ret; + } + + + @Override + @NotNull + public Set getUnitTests() + { + return Set.of( + AggregateQueryDataTransform.TestCase.class, + AttachmentReport.TestCase.class, + FilterClauseBuilder.TestCase.class, + JdbcType.TestCase.class, + MemberSet.TestCase.class, + MetadataElementBase.TestCase.class, + Method.TestCase.class, + QNode.TestCase.class, + Query.TestCase.class, + RemoteConnections.TestCase.class, + ReportsController.SerializationTest.class, + SqlParser.SqlParserTestCase.class, + TableWriter.TestCase.class + ); + } + + @Override + public ActionURL getTabURL(Container c, User user) + { + // Don't show Query nav trails to users who aren't admins or developers since they almost certainly don't want + // to go to those links + if (c.hasOneOf(user, AdminPermission.class, PlatformDeveloperPermission.class)) + { + return super.getTabURL(c, user); + } + return null; + } + + @Override + public JSONObject getPageContextJson(ContainerUser context) + { + JSONObject json = super.getPageContextJson(context); + boolean hasEditQueriesPermission = context.getContainer().hasPermission(context.getUser(), EditQueriesPermission.class); + json.put("hasEditQueriesPermission", hasEditQueriesPermission); + Container container = context.getContainer(); + boolean isProductFoldersEnabled = container != null && container.isProductFoldersEnabled(); // TODO: should these be moved to CoreModule? + json.put(QueryService.PRODUCT_FOLDERS_ENABLED, isProductFoldersEnabled); + json.put(QueryService.PRODUCT_FOLDERS_EXIST, isProductFoldersEnabled && container.hasProductFolders()); + json.put(QueryService.EXPERIMENTAL_PRODUCT_ALL_FOLDER_LOOKUPS, QueryService.get().isProductFoldersAllFolderScopeEnabled()); + json.put(QueryService.EXPERIMENTAL_PRODUCT_PROJECT_DATA_LISTING_SCOPED, QueryService.get().isProductFoldersDataListingScopedToProject()); + return json; + } +} diff --git a/query/src/org/labkey/query/controllers/QueryController.java b/query/src/org/labkey/query/controllers/QueryController.java index dd78fd1ce0b..fb14b8f5b05 100644 --- a/query/src/org/labkey/query/controllers/QueryController.java +++ b/query/src/org/labkey/query/controllers/QueryController.java @@ -307,6 +307,7 @@ import org.springframework.web.multipart.MultipartFile; import org.springframework.web.servlet.ModelAndView; +import javax.net.ssl.SSLException; import java.io.BufferedOutputStream; import java.io.ByteArrayOutputStream; import java.io.File; @@ -541,8 +542,18 @@ public ModelAndView getView(RemoteConnections.RemoteConnectionForm remoteConnect } catch (Exception e) { - errors.addError(new LabKeyError("The listed credentials for this remote connection failed to connect.")); - return new JspView<>("/org/labkey/query/view/testRemoteConnectionsFailure.jsp", remoteConnectionForm); + LOG.warn("Failed to connect for remote connection '{}' to {}", name, url, e); + // SelectRowsStreamHack wraps the underlying failure in a RuntimeException; unwrap to categorize it + Throwable cause = ExceptionUtil.unwrapException(e); + String message; + if (cause instanceof SSLException) + message = "A secure (TLS) connection to the remote server could not be established. This is often caused by an untrusted, self-signed, or expired certificate. "; + else if (cause instanceof IOException) + message = "A connection to the remote server could not be established. "; + else + message = "The listed credentials for this remote connection failed to connect. "; + errors.addError(new LabKeyError(message + RemoteConnections.getBriefMessage(cause))); + return new JspView<>("/org/labkey/query/view/testRemoteConnectionsFailure.jsp", remoteConnectionForm, errors); } return new JspView<>("/org/labkey/query/view/testRemoteConnectionsSuccess.jsp", remoteConnectionForm); diff --git a/query/src/org/labkey/query/view/testRemoteConnectionsFailure.jsp b/query/src/org/labkey/query/view/testRemoteConnectionsFailure.jsp index d40c50d2f8f..323a26eb5d2 100644 --- a/query/src/org/labkey/query/view/testRemoteConnectionsFailure.jsp +++ b/query/src/org/labkey/query/view/testRemoteConnectionsFailure.jsp @@ -21,4 +21,5 @@

The connection using the supplied credentials was not successful.

+ <%=link("manage remote connections", QueryController.RemoteQueryConnectionUrls.urlManageRemoteConnection(getContainer()))%> From 32c28f57cce34e289c9098fae036d1428aafb3c0 Mon Sep 17 00:00:00 2001 From: Josh Eckels Date: Tue, 9 Jun 2026 07:56:40 -0700 Subject: [PATCH 03/17] Specimen module cleanup (#7733) #### Rationale Lint cleanup and SQL improvements #### Changes - Clear assorted IntelliJ warnings - Use JDBC parameter --- .../labkey/specimen/importer/ImportableColumn.java | 6 +++--- .../specimen/importer/RequestabilityManager.java | 11 ++--------- .../specimen/importer/SimpleSpecimenImporter.java | 14 -------------- 3 files changed, 5 insertions(+), 26 deletions(-) diff --git a/specimen/src/org/labkey/specimen/importer/ImportableColumn.java b/specimen/src/org/labkey/specimen/importer/ImportableColumn.java index c556a687278..ee71e0fb59a 100644 --- a/specimen/src/org/labkey/specimen/importer/ImportableColumn.java +++ b/specimen/src/org/labkey/specimen/importer/ImportableColumn.java @@ -24,7 +24,7 @@ public class ImportableColumn private final boolean _maskOnExport; private final boolean _unique; private final int _size; - private final Class _javaClass; + private final Class _javaClass; private final JdbcType _jdbcType; private Object _defaultValue = null; @@ -88,7 +88,7 @@ public ImportableColumn(String tsvColumnName, Collection tsvColumnAliase } // Can't use standard JdbcType.valueOf() method since this uses contains() - private static Class determineJavaType(String dbType) + private static Class determineJavaType(String dbType) { if (dbType.contains(ImportTypes.DATETIME_TYPE)) throw new IllegalStateException("Java types for DateTime/Timestamp columns should be previously initialized."); @@ -148,7 +148,7 @@ public boolean isUnique() return _unique; } - public Class getJavaClass() + public Class getJavaClass() { return _javaClass; } diff --git a/specimen/src/org/labkey/specimen/importer/RequestabilityManager.java b/specimen/src/org/labkey/specimen/importer/RequestabilityManager.java index cc6ccdc0045..3b718aca949 100644 --- a/specimen/src/org/labkey/specimen/importer/RequestabilityManager.java +++ b/specimen/src/org/labkey/specimen/importer/RequestabilityManager.java @@ -395,8 +395,6 @@ public RequestableRule(Container container) public int updateRequestability(User user, List vials) throws InvalidRuleException { TableInfo tableInfoVial = SpecimenSchema.get().getTableInfoVial(_container); - if (null == tableInfoVial) - throw new IllegalStateException("Expected Vial table to exist."); SQLFragment reason = getAvailabilityReason(); SQLFragment updateSQL = new SQLFragment("UPDATE "); @@ -573,7 +571,7 @@ public SQLFragment getFilterSQL(Container container, User user, List vials @Override public SQLFragment getAvailabilityReason() { - return new SQLFragment("'This vial is " + getMarkType().getLabel().toLowerCase() + " because it was found in the set called \"" + _queryName + "\".'"); + return new SQLFragment("?", "This vial is " + getMarkType().getLabel().toLowerCase() + " because it was found in the set called \"" + _queryName + "\"."); } @Override @@ -695,8 +693,6 @@ public LockedWhileProcessingRule(Container container) public SQLFragment getFilterSQL(Container container, User user, List vials) { TableInfo tableInfoVial = SpecimenSchema.get().getTableInfoVial(_container); - if (null == tableInfoVial) - throw new IllegalStateException("Expected Vial table to exist."); SQLFragment sql = new SQLFragment(); if (vials != null && !vials.isEmpty()) @@ -792,8 +788,6 @@ private void updateRequestability(Container container, User user, boolean resetT if (resetToAvailable) { TableInfo tableInfoVial = SpecimenSchema.get().getTableInfoVial(container); - if (null == tableInfoVial) - throw new IllegalStateException("Expected Vial table to exist."); if (logger != null) logger.info("\tResetting vials to default available state."); @@ -847,10 +841,9 @@ public void updateRequestability(Container container, User user, boolean resetTo public static String makeSpecimenUnavailableMessage(Vial vial, @Nullable String additionalText) { - String message = String.format("Specimen %s%s%s", + return String.format("Specimen %s%s%s", vial.getGlobalUniqueId(), null != vial.getAvailabilityReason() ? vial.getAvailabilityReason().replaceFirst("This vial", "") : " is not available.", null != additionalText ? " " + additionalText : ""); - return message; } } diff --git a/specimen/src/org/labkey/specimen/importer/SimpleSpecimenImporter.java b/specimen/src/org/labkey/specimen/importer/SimpleSpecimenImporter.java index 09d4f5f5b1d..13a5cfd54bc 100644 --- a/specimen/src/org/labkey/specimen/importer/SimpleSpecimenImporter.java +++ b/specimen/src/org/labkey/specimen/importer/SimpleSpecimenImporter.java @@ -84,11 +84,6 @@ public class SimpleSpecimenImporter extends SpecimenImporter private final Map _columnLabels; private final TimepointType _timepointType; - public SimpleSpecimenImporter(Container container, User user) - { - this(container, user, TimepointType.DATE, "Subject"); - } - public SimpleSpecimenImporter(Container container, User user, TimepointType timepointType, String participantIdLabel) { super(container, user); @@ -130,15 +125,6 @@ public void fixupSpecimenColumns(TabLoader tl) throws IOException tl.setColumns(mappedCols); } - // UNDONE: Converting values belongs in _process - public List> fixupSpecimenRows(List> rows) - { - List> result = new ArrayList<>(); - for (Map row : rows) - result.add(fixupSpecimenRow(row)); - return result; - } - private Map fixupSpecimenRow(Map row) { Map result = new HashMap<>(); From 49800b1bcecde9f2d7b4fe35a67c4d62ef3e1805 Mon Sep 17 00:00:00 2001 From: Josh Eckels Date: Wed, 10 Jun 2026 20:57:20 -0700 Subject: [PATCH 04/17] Improve scoping checks for objects (#7739) #### Rationale We can improve our parameter validation #### Changes - New helpful base class for integration tests, `AbstractContainerScopingTest` - New test coverage - Assorted scoping checks --- .../AbstractContainerScopingTest.java | 161 ++++++++++++++++++ core/src/org/labkey/core/CoreModule.java | 2 + .../labkey/core/admin/AdminController.java | 39 ++++- .../org/labkey/core/webdav/DavController.java | 143 +++++++++++++++- .../labkey/experiment/ExperimentModule.java | 1 + ...ExternalDocsURLCustomPropertyRenderer.java | 2 +- .../controllers/exp/ExperimentController.java | 55 ++++++ .../org/labkey/issue/IssuesController.java | 120 ++++++++++++- issues/src/org/labkey/issue/IssuesModule.java | 5 +- .../mothership/MothershipController.java | 58 +++++++ .../labkey/mothership/MothershipModule.java | 6 + .../org/labkey/pipeline/PipelineModule.java | 1 + .../pipeline/status/StatusController.java | 44 ++++- .../org/labkey/specimen/SpecimenModule.java | 5 +- .../actions/ShowGroupMembersAction.java | 4 + .../specimen/actions/SpecimenController.java | 93 +++++++++- .../DefaultRequirementProvider.java | 12 +- .../SpecimenRequestRequirementProvider.java | 48 ++++++ study/src/org/labkey/study/StudyModule.java | 3 +- .../controllers/CreateChildStudyAction.java | 134 +++++++++++++-- .../study/model/DatasetImportTestCase.jsp | 15 +- .../study/model/DatasetLsidImportHelper.java | 7 +- .../src/org/labkey/study/model/StudyImpl.java | 8 +- .../org/labkey/study/model/StudyManager.java | 13 ++ .../study/query/DatasetUpdateService.java | 7 +- .../org/labkey/survey/SurveyController.java | 13 ++ .../src/org/labkey/survey/SurveyManager.java | 79 ++++++++- .../src/org/labkey/survey/SurveyModule.java | 8 + wiki/src/org/labkey/wiki/WikiController.java | 85 ++++++--- wiki/src/org/labkey/wiki/WikiModule.java | 3 +- 30 files changed, 1091 insertions(+), 83 deletions(-) create mode 100644 api/src/org/labkey/api/security/permissions/AbstractContainerScopingTest.java diff --git a/api/src/org/labkey/api/security/permissions/AbstractContainerScopingTest.java b/api/src/org/labkey/api/security/permissions/AbstractContainerScopingTest.java new file mode 100644 index 00000000000..bb5db4d34a6 --- /dev/null +++ b/api/src/org/labkey/api/security/permissions/AbstractContainerScopingTest.java @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2026 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.api.security.permissions; + +import org.junit.After; +import org.junit.Assert; +import org.labkey.api.data.Container; +import org.labkey.api.data.ContainerManager; +import org.labkey.api.security.MutableSecurityPolicy; +import org.labkey.api.security.SecurityManager; +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.roles.Role; +import org.labkey.api.util.JunitUtil; +import org.labkey.api.util.TestContext; +import org.labkey.api.view.ActionURL; +import org.labkey.api.view.ViewServlet; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Base class for "container scoping" (a.k.a. broken-object-level-authorization / BOLA / IDOR) integration tests. These + * tests verify that an action whose {@code @RequiresPermission} annotation is correct for the current container still + * rejects an object resolved by a global id that belongs to a different container. + * + *

The repeated scaffolding lives here so each subclass keeps only its data fixture and the action under test: + *

    + *
  • {@link #createContainer(String)} — make a throwaway child of the junit container (auto-cleaned).
  • + *
  • {@link #createUserInRole(Container, Class)} — make a user with a role assigned in one folder only + * (auto-cleaned). Use this to obtain a caller who is, say, admin in folder A but has no rights in folder B.
  • + *
  • {@link #get(ActionURL, User)} / {@link #post(ActionURL, User)} — dispatch an in-JVM request as a given user + * and inspect the {@link MockHttpServletResponse} status. Parameters travel on the {@link ActionURL}.
  • + *
+ * + *

Note: WebDAV verbs (MOVE, PROPPATCH, ...) are not served through {@link ViewServlet} dispatch, so a WebDAV test + * should still use this class for its container/user fixtures but drive the verb through {@code WebdavServlet} itself. + */ +public abstract class AbstractContainerScopingTest extends Assert +{ + private static final Map FORM_HEADERS = Map.of("Content-Type", "application/x-www-form-urlencoded"); + + private final List _containers = new ArrayList<>(); + private final List _users = new ArrayList<>(); + + /** The site-admin user (from {@link TestContext}) that owns the test fixtures. */ + protected User getAdmin() + { + return TestContext.get().getUser(); + } + + /** + * Create a throwaway child container of the junit container, named uniquely per test class, registered for + * automatic cleanup. Callers pass a short local name (e.g. "A"/"B"/"Source"); the class name is prepended so two + * test classes can both ask for "A" without colliding. + */ + protected Container createContainer(String name) + { + Container junit = JunitUtil.getTestContainer(); + Container c = ContainerManager.ensureContainer(junit.getParsedPath().append(getClass().getSimpleName() + "-" + name, true), getAdmin()); + _containers.add(c); + return c; + } + + /** + * Create a user that has {@code role} assigned in {@code scope} only (it has no rights in any other + * container), registered for automatic cleanup. This is the canonical way to build a caller who is privileged in + * one folder but not another. Do not use {@code LimitedUser} for this — that grants roles unconditionally in every + * container. + */ + protected User createUserInRole(Container scope, Class role) throws Exception + { + String email = getClass().getSimpleName().toLowerCase() + "-" + _users.size() + "@containerscoping.test"; + User user = SecurityManager.addUser(new ValidEmail(email), null).getUser(); + _users.add(user); + grantRole(user, scope, role); + return user; + } + + /** + * Grant {@code role} to an existing {@code user} in {@code scope}, on top of any roles it already holds in that + * container. Use this to build a caller with different roles in different folders (e.g. delete rights in a source + * folder but only read access in a target folder). + */ + protected void grantRole(User user, Container scope, Class role) throws Exception + { + MutableSecurityPolicy policy = new MutableSecurityPolicy(scope.getPolicy()); + policy.addRoleAssignment(user, role); + SecurityPolicyManager.savePolicyForTests(policy, getAdmin()); + } + + /** + * Dispatch a GET to the action addressed by {@code url} as {@code user}. Put request parameters on the URL. No + * request-body Content-Type is sent: a GET carries no body, and an "application/json" Content-Type would make an + * API action ({@code ReadOnlyApiAction}) try to parse the empty body as JSON and fail with 400 before its + * {@code execute()} runs. With no Content-Type the form binds from the URL parameters, as a real GET would. + */ + protected MockHttpServletResponse get(ActionURL url, User user) throws Exception + { + return ViewServlet.GET(url, user, Map.of()); + } + + /** Dispatch a POST to the action addressed by {@code url} as {@code user}. Put request parameters on the URL. */ + protected MockHttpServletResponse post(ActionURL url, User user) throws Exception + { + return ViewServlet.POST(url, user, FORM_HEADERS, null); + } + + /** Assert that a dispatched response has the expected HTTP status code. */ + protected void assertStatus(int expected, MockHttpServletResponse response) + { + assertEquals("Unexpected HTTP status", expected, response.getStatus()); + } + + @After + public void cleanupContainerScopingFixtures() + { + User admin = getAdmin(); + + for (User user : _users) + { + try + { + UserManager.deleteUser(user.getUserId()); + } + catch (Exception ignored) + { + } + } + _users.clear(); + + for (Container c : _containers) + { + try + { + ContainerManager.deleteAll(c, admin); + } + catch (Exception ignored) + { + } + } + _containers.clear(); + } +} diff --git a/core/src/org/labkey/core/CoreModule.java b/core/src/org/labkey/core/CoreModule.java index 76db398883d..bd342ae5afa 100644 --- a/core/src/org/labkey/core/CoreModule.java +++ b/core/src/org/labkey/core/CoreModule.java @@ -1394,11 +1394,13 @@ public Set getIntegrationTests() AdminController.SerializationTest.class, AdminController.TestCase.class, AdminController.WorkbookDeleteTestCase.class, + AdminController.ImportFolderSourceScopingTestCase.class, AllowListType.TestCase.class, AttachmentServiceImpl.TestCase.class, CoreController.TestCase.class, DataRegion.TestCase.class, DavController.TestCase.class, + DavController.MoveActionContainerScopingTestCase.class, EmailServiceImpl.TestCase.class, FilesSiteSettingsAction.TestCase.class, LoggerController.TestCase.class, diff --git a/core/src/org/labkey/core/admin/AdminController.java b/core/src/org/labkey/core/admin/AdminController.java index 3ae8e54dee7..8868dcbf947 100644 --- a/core/src/org/labkey/core/admin/AdminController.java +++ b/core/src/org/labkey/core/admin/AdminController.java @@ -209,6 +209,7 @@ import org.labkey.api.security.impersonation.RoleImpersonationContextFactory; import org.labkey.api.security.impersonation.UserImpersonationContextFactory; import org.labkey.api.security.permissions.AbstractActionPermissionTest; +import org.labkey.api.security.permissions.AbstractContainerScopingTest; import org.labkey.api.security.permissions.AdminOperationsPermission; import org.labkey.api.security.permissions.AdminPermission; import org.labkey.api.security.permissions.ApplicationAdminPermission; @@ -5393,6 +5394,10 @@ public boolean handlePost(ImportFolderForm form, BindException errors) throws Ex if (!StringUtils.isEmpty(form.getSourceTemplateFolder())) { fiConfig = getFolderImportConfigFromTemplateFolder(form, pipelineUnzipDir, errors); + if (fiConfig == null || errors.hasErrors()) + { + return false; + } } else { @@ -5488,10 +5493,16 @@ public boolean handlePost(ImportFolderForm form, BindException errors) throws Ex private FolderImportConfig getFolderImportConfigFromTemplateFolder(final ImportFolderForm form, final Path pipelineUnzipDir, final BindException errors) throws Exception { - // user choose to import from a template source folder + // user chose to import from a template source folder Container sourceContainer = form.getSourceTemplateFolderContainer(); - // In order to support the Advanced import options to import into multiple target folders we need to zip + if (sourceContainer == null || !sourceContainer.hasPermission(getUser(), AdminPermission.class)) + { + errors.reject(SpringActionController.ERROR_MSG, "You do not have permission to import from the specified source folder."); + return null; + } + + // To support the Advanced import options to import into multiple target folders we need to zip // the source template folder so that the zip file can be passed to the pipeline processes. FolderExportContext ctx = new FolderExportContext(getUser(), sourceContainer, getRegisteredFolderWritersForImplicitExport(sourceContainer), "new", false, @@ -12286,4 +12297,28 @@ protected static void doCleanup() throws Exception } } } + + public static class ImportFolderSourceScopingTestCase extends AbstractContainerScopingTest + { + @Test + public void testImportFromTemplateRequiresSourceAdmin() throws Exception + { + Container dest = createContainer("Dest"); + Container source = createContainer("Source"); + User destAdminOnly = createUserInRole(dest, FolderAdminRole.class); + + ActionURL url = new ActionURL(ImportFolderAction.class, dest) + .addParameter("sourceTemplateFolder", source.getPath()) + .addParameter("sourceTemplateFolderId", source.getId()); + MockHttpServletResponse resp = post(url, destAdminOnly); + + // The fix rejects the import and reshows the form (200) rather than redirecting to success (302), with a + // source-permission error message in the rendered content. + assertStatus(HttpServletResponse.SC_OK, resp); + assertTrue("Expected a source-permission rejection message, content was: " + resp.getContentAsString(), + resp.getContentAsString().contains("permission to import from the specified source folder")); + + // Positive control performed in S3ImportTest.testS3Import(). Difficult to mock here due to pipeline job + } + } } diff --git a/core/src/org/labkey/core/webdav/DavController.java b/core/src/org/labkey/core/webdav/DavController.java index 76fac322759..4b5590749db 100644 --- a/core/src/org/labkey/core/webdav/DavController.java +++ b/core/src/org/labkey/core/webdav/DavController.java @@ -32,6 +32,7 @@ import org.json.JSONObject; import org.json.JSONWriter; import org.junit.Assert; +import org.junit.Before; import org.junit.Test; import org.labkey.api.action.ApiUsageException; import org.labkey.api.action.BaseViewAction; @@ -66,8 +67,12 @@ import org.labkey.api.security.SecurityManager; import org.labkey.api.security.User; import org.labkey.api.security.UserManager; +import org.labkey.api.security.permissions.AbstractContainerScopingTest; import org.labkey.api.security.permissions.BrowserDeveloperPermission; import org.labkey.api.security.permissions.ReadPermission; +import org.labkey.api.security.roles.AuthorRole; +import org.labkey.api.security.roles.EditorRole; +import org.labkey.api.security.roles.ReaderRole; import org.labkey.api.settings.AppProps; import org.labkey.api.settings.LookAndFeelProperties; import org.labkey.api.settings.OptionalFeatureService; @@ -99,6 +104,8 @@ import org.labkey.vfs.FileLike; import org.springframework.beans.MutablePropertyValues; import org.springframework.beans.PropertyValues; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.validation.BindException; import org.springframework.web.multipart.MultipartException; import org.springframework.web.multipart.MultipartFile; @@ -122,10 +129,8 @@ import jakarta.servlet.http.HttpSession; import jakarta.servlet.http.Part; import javax.xml.parsers.DocumentBuilder; -import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; import javax.xml.parsers.SAXParser; -import javax.xml.parsers.SAXParserFactory; import java.io.BufferedInputStream; import java.io.BufferedWriter; import java.io.ByteArrayInputStream; @@ -135,6 +140,7 @@ import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; +import java.io.FileOutputStream; import java.io.FilterInputStream; import java.io.FilterWriter; import java.io.IOException; @@ -3720,6 +3726,9 @@ WebdavStatus doMethod() throws DavException, IOException if (!src.canRead(getUser(), true)) return unauthorized(src); + // MOVE is effectively a delete operation from the source's perspective so confirm access + if (!src.canDelete(getUser(), true)) + return unauthorized(src); if (exists && !dest.canWrite(getUser(),true) || !exists && !dest.canCreate(getUser(),true)) return unauthorized(dest); @@ -6630,4 +6639,134 @@ public void testElementName() } } + + public static class MoveActionContainerScopingTestCase extends AbstractContainerScopingTest + { + private Container _folder; + private User _mover; + + @Before + public void setUp() throws Exception + { + _folder = createContainer("Folder"); + // Author = Read + Insert but NOT Delete. This lets the caller pass the source-read and destination-create + // checks so a same-folder MOVE's outcome turns solely on the source-delete guard under test. + _mover = createUserInRole(_folder, AuthorRole.class); + } + + // Documents the precondition the MOVE fix relies on: MOVE removes the source, so it requires Delete there. + // An Author can read and create but, lacking Delete, must not be able to rename/move. + @Test + public void testRenamePermissionInvariant() + { + WebdavResource files = WebdavService.get().lookup(filesPath(_folder)); + assertNotNull("Expected a @files webdav node for the test folder", files); + assertTrue("Author should be able to read @files", files.canRead(_mover, true)); + assertTrue("Author should be able to create in @files (so the destination check passes)", files.canCreate(_mover, true)); + assertFalse("Author must NOT be able to rename/move @files (lacks Delete)", files.canRename(_mover, true)); + assertTrue("Admin should be able to rename @files", files.canRename(getAdmin(), true)); + } + + // Drives an actual MOVE through WebdavServlet/DavController. The caller can read and create in the (single) + // folder but cannot delete, so only the source-delete guard can forbid the move. Without the fix the move + // succeeds (SC_CREATED), removing a file the caller has no right to delete; with the fix it is forbidden. + @Test + public void testMoveActionRequiresSourceDelete() throws Exception + { + File dir = ensureFilesDir(_folder); + File srcFile = writeFile(dir); + assertTrue("Source file should resolve through the resolver", WebdavService.get().lookup(filesPath(_folder).append("secret.txt")).exists()); + + Path src = filesPath(_folder).append("secret.txt"); + Path dest = filesPath(_folder).append("moved.txt"); + + MockHttpServletResponse resp = doMove(_folder, src, dest, _mover); + assertEquals("A caller without Delete on the source must be forbidden from MOVE", HttpServletResponse.SC_FORBIDDEN, resp.getStatus()); + assertTrue("Source file must still exist after a forbidden move", srcFile.exists()); + + // Positive control: the same MOVE driven by an admin (who has Delete, hence rename) succeeds, proving the + // guard forbids only the under-privileged caller rather than every MOVE. + MockHttpServletResponse adminResp = doMove(_folder, src, dest, getAdmin()); + assertEquals("An admin must be allowed to MOVE", HttpServletResponse.SC_CREATED, adminResp.getStatus()); + assertFalse("Source file should no longer exist after a successful move", srcFile.exists()); + assertTrue("Destination file should exist after a successful move", new File(dir, "moved.txt").exists()); + } + + // Ensure moves by a caller who CAN delete in the source but lacks Insert in the target are forbidden + @Test + public void testMoveActionRequiresTargetCreate() throws Exception + { + Container source = createContainer("DeleteSource"); + Container target = createContainer("NoInsertTarget"); + + // Editor in the source (Read+Insert+Update+Delete) -> passes the source read and delete checks. + // Reader in the target (no Insert) -> proves read access to the target is not enough; Insert is required. + User editorInSourceOnly = createUserInRole(source, EditorRole.class); + grantRole(editorInSourceOnly, target, ReaderRole.class); + + File srcDir = ensureFilesDir(source); + File targetDir = ensureFilesDir(target); + File srcFile = writeFile(srcDir); + + Path src = filesPath(source).append("secret.txt"); + Path dest = filesPath(target).append("moved.txt"); + + MockHttpServletResponse resp = doMove(source, src, dest, editorInSourceOnly); + assertEquals("A caller without Insert on the target must be forbidden from MOVE", HttpServletResponse.SC_FORBIDDEN, resp.getStatus()); + assertTrue("Source file must still exist after a forbidden move", srcFile.exists()); + assertFalse("Destination file must not have been created", FileUtil.appendName(targetDir, "moved.txt").exists()); + + // Positive control: a caller who is also Editor (hence Insert) in the target can complete the same + // cross-container MOVE, proving the destination guard forbids only the under-privileged caller. + User editorInBoth = createUserInRole(source, EditorRole.class); + grantRole(editorInBoth, target, EditorRole.class); + MockHttpServletResponse okResp = doMove(source, src, dest, editorInBoth); + assertEquals("With Insert on the target the MOVE must succeed", HttpServletResponse.SC_CREATED, okResp.getStatus()); + assertFalse("Source file should no longer exist after a successful move", srcFile.exists()); + assertTrue("Destination file should exist after a successful move", FileUtil.appendName(targetDir, "moved.txt").exists()); + } + + private static Path filesPath(Container c) + { + return WebdavService.getPath().append(c.getParsedPath()).append(FileContentService.FILES_LINK); + } + + private static File ensureFilesDir(Container c) + { + WebdavResource filesNode = WebdavService.get().lookup(filesPath(c)); + assertNotNull("Test requires a @files node for " + c.getName(), filesNode); + File dir = filesNode.getFile(); + assertNotNull("Test requires a file root for " + c.getName(), dir); + if (!dir.exists()) + dir.mkdirs(); + return dir; + } + + private static File writeFile(File dir) throws IOException + { + File f = FileUtil.appendName(dir, "secret.txt"); + try (FileOutputStream os = new FileOutputStream(f)) + { + os.write("secret".getBytes(StandardCharsets.UTF_8)); + } + return f; + } + + private static MockHttpServletResponse doMove(Container sourceContainer, Path srcResource, Path destResource, User user) throws Exception + { + String srcWebdav = srcResource.toString(); + String destWebdav = destResource.toString(); + String servletPath = "/" + WebdavService.getServletPath(); + + HttpServletRequest base = ViewServlet.mockRequest("MOVE", new ActionURL(name, "move", sourceContainer), user, Map.of("Destination", destWebdav), null); + MockHttpServletRequest req = (MockHttpServletRequest) base; + req.setServletPath(servletPath); + req.setPathInfo(srcWebdav.substring(servletPath.length())); + req.setRequestURI(srcWebdav); + + MockHttpServletResponse resp = new MockHttpServletResponse(); + new WebdavServlet(false).service(req, resp); + return resp; + } + } } diff --git a/experiment/src/org/labkey/experiment/ExperimentModule.java b/experiment/src/org/labkey/experiment/ExperimentModule.java index 7dfb0c6dbd6..810160a20c4 100644 --- a/experiment/src/org/labkey/experiment/ExperimentModule.java +++ b/experiment/src/org/labkey/experiment/ExperimentModule.java @@ -949,6 +949,7 @@ public Collection getSummary(Container c) public Set getIntegrationTests() { return Set.of( + ExperimentController.ContainerScopingTestCase.class, DomainImpl.TestCase.class, DomainPropertyImpl.TestCase.class, ExpDataTableImpl.TestCase.class, diff --git a/experiment/src/org/labkey/experiment/ExternalDocsURLCustomPropertyRenderer.java b/experiment/src/org/labkey/experiment/ExternalDocsURLCustomPropertyRenderer.java index e6544c8755a..604b1cf6768 100644 --- a/experiment/src/org/labkey/experiment/ExternalDocsURLCustomPropertyRenderer.java +++ b/experiment/src/org/labkey/experiment/ExternalDocsURLCustomPropertyRenderer.java @@ -78,6 +78,6 @@ public String getValue(ObjectProperty prop, List siblingProperti { // That's OK, we won't try to do anything with the link } - return "" + PageFlowUtil.filter(label) + ""; + return "" + PageFlowUtil.filter(label) + ""; } } diff --git a/experiment/src/org/labkey/experiment/controllers/exp/ExperimentController.java b/experiment/src/org/labkey/experiment/controllers/exp/ExperimentController.java index 67c15cdc4cf..cc2ffd6445f 100644 --- a/experiment/src/org/labkey/experiment/controllers/exp/ExperimentController.java +++ b/experiment/src/org/labkey/experiment/controllers/exp/ExperimentController.java @@ -31,6 +31,7 @@ import org.json.JSONArray; import org.json.JSONException; 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.ApiSimpleResponse; @@ -59,6 +60,7 @@ import org.labkey.api.attachments.AttachmentParent; import org.labkey.api.attachments.AttachmentService; import org.labkey.api.attachments.BaseDownloadAction; +import org.labkey.api.attachments.ByteArrayAttachmentFile; import org.labkey.api.audit.AbstractAuditTypeProvider; import org.labkey.api.audit.AuditLogService; import org.labkey.api.audit.DetailedAuditTypeEvent; @@ -187,6 +189,7 @@ import org.labkey.api.security.RequiresPermission; import org.labkey.api.security.SecurableResource; import org.labkey.api.security.User; +import org.labkey.api.security.permissions.AbstractContainerScopingTest; import org.labkey.api.security.permissions.AdminPermission; import org.labkey.api.security.permissions.DeletePermission; import org.labkey.api.security.permissions.DesignDataClassPermission; @@ -198,6 +201,7 @@ import org.labkey.api.security.permissions.SiteAdminPermission; import org.labkey.api.security.permissions.TroubleshooterPermission; import org.labkey.api.security.permissions.UpdatePermission; +import org.labkey.api.security.roles.ReaderRole; import org.labkey.api.settings.AppProps; import org.labkey.api.settings.ConceptURIProperties; import org.labkey.api.sql.LabKeySql; @@ -298,6 +302,7 @@ import org.labkey.vfs.FileLike; import org.springframework.beans.PropertyValue; import org.springframework.beans.PropertyValues; +import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.validation.BindException; import org.springframework.validation.Errors; import org.springframework.validation.ObjectError; @@ -316,6 +321,7 @@ import java.io.InputStream; import java.io.OutputStream; import java.net.URI; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.sql.SQLException; @@ -386,6 +392,10 @@ public static void ensureCorrectContainer(Container requestContainer, ExpObject Container objectContainer = object.getContainer(); if (!requestContainer.equals(objectContainer)) { + // Only redirect if the user can read the object's container; otherwise don't reveal that it exists + if (objectContainer == null || !objectContainer.hasPermission(viewContext.getUser(), ReadPermission.class)) + throw new NotFoundException(); + ActionURL url = viewContext.cloneActionURL(); url.setContainer(objectContainer); throw new RedirectException(url); @@ -1797,6 +1807,8 @@ public Pair getAttachment(AttachmentForm form) ExpData data = ExperimentServiceImpl.get().getExpData(lsid.toString()); if (data == null) throw new NotFoundException("Error: Data object not found for the given LSID: " + lsid); + // The LSID could be from another container. If so, redirect there + ensureCorrectContainer(getContainer(), data, getViewContext()); AttachmentParent parent = new ExpDataClassAttachmentParent(data.getContainer(), lsid); return new Pair<>(parent, form.getName()); @@ -8335,4 +8347,47 @@ public Object execute(Object o, BindException errors) throws Exception } } + public static class ContainerScopingTestCase extends AbstractContainerScopingTest + { + @Test + public void testDataClassAttachmentContainerScoping() throws Exception + { + User admin = getAdmin(); + Container folderA = createContainer("A"); + Container folderB = createContainer("B"); + User readerA = createUserInRole(folderA, ReaderRole.class); + + // A data object that lives in folder B, with a real attachment so the legitimate download path can be exercised. + String attachmentName = "attachment.txt"; + String lsid = ExperimentService.get().generateGuidLSID(folderB, ExpData.class); + ExpData data = ExperimentService.get().createData(folderB, "exp1-scope-test", lsid); + data.save(admin); + AttachmentParent parent = new ExpDataClassAttachmentParent(folderB, new Lsid(lsid)); + AttachmentService.get().addAttachments(parent, List.of(new ByteArrayAttachmentFile(attachmentName, "scope test".getBytes(StandardCharsets.UTF_8), "text/plain")), admin); + + ActionURL foreignUrl = new ActionURL(DataClassAttachmentDownloadAction.class, folderA) + .addParameter("lsid", lsid) + .addParameter("name", attachmentName); + + // Deny branch: a caller who can read folder A but NOT folder B must not learn that B's data exists -> 404, + // rather than being redirected (which would leak B's existence and path). + assertStatus(HttpServletResponse.SC_NOT_FOUND, get(foreignUrl, readerA)); + + // Redirect branch: a caller who CAN read folder B is redirected to its own container (where Read is + // re-enforced) rather than served from folder A. Fails without the ensureCorrectContainer call. + MockHttpServletResponse resp = get(foreignUrl, admin); + assertStatus(HttpServletResponse.SC_FOUND, resp); + String location = resp.getRedirectedUrl(); + assertNotNull("Redirect must have a Location", location); + assertTrue("Redirect should target the data's own container, was: " + location, location.contains(folderB.getPath())); + + // Positive control: addressing the attachment through its own container serves the file (200), proving the + // action isn't simply rejecting every request. + ActionURL ownUrl = new ActionURL(DataClassAttachmentDownloadAction.class, folderB) + .addParameter("lsid", lsid) + .addParameter("name", attachmentName); + assertStatus(HttpServletResponse.SC_OK, get(ownUrl, admin)); + } + } + } diff --git a/issues/src/org/labkey/issue/IssuesController.java b/issues/src/org/labkey/issue/IssuesController.java index f2b9b65184a..ee2eb357a1a 100644 --- a/issues/src/org/labkey/issue/IssuesController.java +++ b/issues/src/org/labkey/issue/IssuesController.java @@ -28,6 +28,7 @@ import org.jetbrains.annotations.Nullable; import org.json.JSONArray; import org.json.JSONObject; +import org.junit.Test; import org.labkey.api.action.ApiResponse; import org.labkey.api.action.ApiSimpleResponse; import org.labkey.api.action.BaseViewAction; @@ -79,6 +80,7 @@ import org.labkey.api.issues.IssuesListDefService; import org.labkey.api.issues.IssuesSchema; import org.labkey.api.issues.IssuesUrls; +import org.labkey.api.module.Module; import org.labkey.api.module.ModuleHtmlView; import org.labkey.api.module.ModuleLoader; import org.labkey.api.query.FieldKey; @@ -99,11 +101,14 @@ import org.labkey.api.security.SecurityManager; import org.labkey.api.security.User; import org.labkey.api.security.UserManager; +import org.labkey.api.security.permissions.AbstractContainerScopingTest; 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.permissions.UpdatePermission; +import org.labkey.api.security.roles.FolderAdminRole; import org.labkey.api.security.roles.OwnerRole; +import org.labkey.api.security.roles.ReaderRole; import org.labkey.api.security.roles.RoleManager; import org.labkey.api.util.ButtonBuilder; import org.labkey.api.util.CSRFUtil; @@ -113,6 +118,7 @@ import org.labkey.api.util.JsonUtil; import org.labkey.api.util.PageFlowUtil; import org.labkey.api.util.Pair; +import org.labkey.api.util.TestContext; import org.labkey.api.util.URLHelper; import org.labkey.api.util.InputBuilder; import org.labkey.api.view.ActionURL; @@ -1633,9 +1639,41 @@ public static class MoveAction extends MutatingApiAction @Override public ApiResponse execute(MoveIssueForm form, BindException errors) { + if (form.getIssueIds() == null || form.getIssueIds().length == 0) + throw new NotFoundException("No issues specified to move"); + + // The client supplies the destination; resolve it and validate it per issue below + Container dest = form.getTargetContainerId() != null ? ContainerManager.getForId(form.getTargetContainerId()) : null; + if (dest == null) + throw new NotFoundException("Target container not found"); + + if (!dest.hasPermission(getUser(), AdminPermission.class)) + throw new UnauthorizedException(); + + List issueIds = Arrays.asList(form.getIssueIds()); + for (Integer issueId : issueIds) + { + // getIssue(null, ...) resolves by global id, so each issue and the chosen destination must be + // authorized explicitly rather than relying on the current container's Admin grant. + IssueObject issue = IssueManager.getIssue(null, getUser(), issueId); + if (issue == null) + throw new NotFoundException("Issue not found: " + issueId); + + Container source = issue.getContainerFromId(); + // Moving an issue removes it from its source folder, so require Admin there. + if (source == null || !source.hasPermission(getUser(), AdminPermission.class)) + throw new UnauthorizedException(); + + // The destination must be a legitimate move target for this issue's list definition. This also + // confirms the user can access the matching issue list in the destination. + IssueListDef issueListDef = IssueManager.getIssueListDef(issue); + if (issueListDef == null || !IssueManager.getMoveDestinationContainers(source, getUser(), issueListDef.getName()).contains(dest)) + throw new UnauthorizedException(); + } + try { - IssueManager.moveIssues(getUser(), Arrays.asList(form.getIssueIds()), ContainerManager.getForId(form.getTargetContainerId())); + IssueManager.moveIssues(getUser(), issueIds, dest); } catch (IOException x) { @@ -2340,4 +2378,84 @@ public void setIssueId(int issueId) this.issueId = issueId; } } + + public static class MoveActionContainerScopingTestCase extends AbstractContainerScopingTest + { + @Test + public void testMoveRequiresSourceAdmin() throws Exception + { + // Admin in the destination only + // Positive control is in IssuesTest.moveIssueTest(). + assertCrossContainerMoveRejected(MoverScope.DESTINATION); + } + + @Test + public void testMoveRequiresDestinationAdmin() throws Exception + { + // Admin in the source only: driving the action through the source folder gets past @RequiresPermission and + // the source-admin guard, so the move's outcome turns solely on the destination-admin guard -> 403. + assertCrossContainerMoveRejected(MoverScope.SOURCE); + } + + private enum MoverScope { SOURCE, DESTINATION } + + /** + * Create a source and destination folder, put an issue in the source (owned by the site admin), then attempt to + * move it as a caller who is a folder admin in exactly one of the two folders ({@code moverScope}). + */ + private void assertCrossContainerMoveRejected(MoverScope moverScope) throws Exception + { + User admin = getAdmin(); + Container source = createContainer("Source"); // the issue lives here + Container dest = createContainer("Dest"); + + ensureIssuesEnabled(source); + + // Create an issue in the source folder (as the site admin) + IssueObject issue = new IssueObject(); + issue.open(source, admin); + issue.setAssignedTo(admin.getUserId()); + issue.setTitle("Scoping test issue"); + issue.setPriority("3"); + issue.setIssueDefName(IssueListDef.DEFAULT_ISSUE_LIST_NAME); + ObjectFactory.Registry.getFactory(IssueObject.class).toMap(issue, issue.getProperties()); + IssueManager.saveIssue(admin, source, issue); + int issueId = issue.getIssueId(); + + // A folder admin in exactly one of the two folders; drive the action through that folder + Container moverFolder = moverScope == MoverScope.SOURCE ? source : dest; + User mover = createUserInRole(moverFolder, FolderAdminRole.class); + // Make them a reader in the other folder + grantRole(mover, moverScope == MoverScope.SOURCE ? dest : source, ReaderRole.class); + + ActionURL url = new ActionURL(MoveAction.class, moverFolder) + .addParameter("issueIds", String.valueOf(issueId)) + .addParameter("targetContainerId", dest.getId()); + assertStatus(HttpServletResponse.SC_FORBIDDEN, post(url, mover)); + + // The issue must remain in its source container + assertNotNull("Issue should still exist in its source folder", IssueManager.getIssue(source, admin, issueId)); + } + + private static void ensureIssuesEnabled(Container c) + { + Module issueModule = ModuleLoader.getInstance().getModule(IssuesModule.NAME); + Set activeModules = c.getActiveModules(); + if (!activeModules.contains(issueModule)) + { + Set newActiveModules = new HashSet<>(activeModules); + newActiveModules.add(issueModule); + c.setActiveModules(newActiveModules); + } + if (IssueManager.getIssueListDef(c, IssueListDef.DEFAULT_ISSUE_LIST_NAME) == null) + { + IssueListDef def = new IssueListDef(); + def.setName(IssueListDef.DEFAULT_ISSUE_LIST_NAME); + def.setLabel(IssueListDef.DEFAULT_ISSUE_LIST_NAME); + def.setKind(IssueDefDomainKind.NAME); + def.beforeInsert(TestContext.get().getUser(), c.getId()); + def.save(TestContext.get().getUser()); + } + } + } } diff --git a/issues/src/org/labkey/issue/IssuesModule.java b/issues/src/org/labkey/issue/IssuesModule.java index 63c9a7aa91e..06630d6dbad 100644 --- a/issues/src/org/labkey/issue/IssuesModule.java +++ b/issues/src/org/labkey/issue/IssuesModule.java @@ -193,7 +193,10 @@ public ActionURL getTabURL(Container c, User user) @NotNull public Set getIntegrationTests() { - return Collections.singleton(org.labkey.issue.model.IssueManager.TestCase.class); + return Set.of( + org.labkey.issue.model.IssueManager.TestCase.class, + org.labkey.issue.IssuesController.MoveActionContainerScopingTestCase.class + ); } @Override diff --git a/mothership/src/org/labkey/mothership/MothershipController.java b/mothership/src/org/labkey/mothership/MothershipController.java index f3284469b78..398dec07b60 100644 --- a/mothership/src/org/labkey/mothership/MothershipController.java +++ b/mothership/src/org/labkey/mothership/MothershipController.java @@ -16,6 +16,7 @@ package org.labkey.mothership; +import jakarta.servlet.http.HttpServletResponse; import org.apache.commons.beanutils.ConversionException; import org.apache.commons.lang3.StringUtils; import org.apache.commons.validator.routines.InetAddressValidator; @@ -23,6 +24,7 @@ import org.jetbrains.annotations.NotNull; import org.json.JSONException; import org.json.JSONObject; +import org.junit.Test; import org.labkey.api.action.BaseApiAction; import org.labkey.api.action.BaseViewAction; import org.labkey.api.action.FormHandlerAction; @@ -49,6 +51,7 @@ import org.labkey.api.data.RenderContext; import org.labkey.api.data.SimpleFilter; import org.labkey.api.data.Sort; +import org.labkey.api.data.Table; import org.labkey.api.data.TableInfo; import org.labkey.api.data.TableSelector; import org.labkey.api.module.AllowedDuringUpgrade; @@ -64,6 +67,7 @@ import org.labkey.api.security.RequiresSiteAdmin; import org.labkey.api.security.User; import org.labkey.api.security.UserManager; +import org.labkey.api.security.permissions.AbstractContainerScopingTest; import org.labkey.api.security.permissions.AdminPermission; import org.labkey.api.security.permissions.ReadPermission; import org.labkey.api.security.permissions.UpdatePermission; @@ -562,6 +566,22 @@ public void validateCommand(ServerInstallationForm target, Errors errors) @Override public boolean handlePost(ServerInstallationForm form, BindException errors) throws Exception { + // Confirm the row belongs to the current container + Object pk = form.getPkVal(); + if (pk == null) + throw new NotFoundException("No server installation specified"); + int installationId; + try + { + installationId = Integer.parseInt(String.valueOf(pk)); + } + catch (NumberFormatException e) + { + throw new NotFoundException("Invalid server installation id: " + pk); + } + if (MothershipManager.get().getServerInstallation(installationId, getContainer()) == null) + throw new NotFoundException("Server installation not found in this folder"); + form.doUpdate(); return true; } @@ -1975,5 +1995,43 @@ public void setUptimeContainer(String uptimeContainer) _uptimeContainer = uptimeContainer; } } + + public static class ContainerScopingTestCase extends AbstractContainerScopingTest + { + @Test + public void testUpdateInstallationContainerScoping() throws Exception + { + User admin = getAdmin(); + Container folderA = createContainer("A"); + Container folderB = createContainer("B"); + + // An installation row that lives in folder B + ServerInstallation si = new ServerInstallation(); + si.setContainer(folderB.getId()); + si.setServerInstallationGUID(GUID.makeGUID()); + si.setNote("original"); + si = Table.insert(admin, MothershipManager.get().getTableInfoServerInstallation(), si); + int id = si.getServerInstallationId(); + + // Try to update it through folder A; the fix resolves the row in the current container and 404s on a miss + ActionURL url = new ActionURL(UpdateInstallationAction.class, folderA) + .addParameter("ServerInstallationId", String.valueOf(id)) + .addParameter("Note", "hacked"); + assertStatus(HttpServletResponse.SC_NOT_FOUND, post(url, admin)); + + // The row in folder B must be untouched + ServerInstallation reloaded = MothershipManager.get().getServerInstallation(id, folderB); + assertNotNull("Installation should still exist in its own container", reloaded); + assertEquals("Note must not have been overwritten", "original", reloaded.getNote()); + + // Positive control: updating through the row's own container (folder B) succeeds and persists the change, + // proving the guard rejects only the cross-container case, not every update. + ActionURL ownUrl = new ActionURL(UpdateInstallationAction.class, folderB) + .addParameter("ServerInstallationId", String.valueOf(id)) + .addParameter("Note", "updated"); + assertStatus(HttpServletResponse.SC_FOUND, post(ownUrl, admin)); + assertEquals("Note should have been updated through the row's own container", "updated", MothershipManager.get().getServerInstallation(id, folderB).getNote()); + } + } } diff --git a/mothership/src/org/labkey/mothership/MothershipModule.java b/mothership/src/org/labkey/mothership/MothershipModule.java index 471eecdde46..99f7cf6f574 100644 --- a/mothership/src/org/labkey/mothership/MothershipModule.java +++ b/mothership/src/org/labkey/mothership/MothershipModule.java @@ -116,6 +116,12 @@ public Set getUnitTests() return PageFlowUtil.set(ExceptionStackTrace.TestCase.class); } + @Override + public @NotNull Set getIntegrationTests() + { + return PageFlowUtil.set(MothershipController.ContainerScopingTestCase.class); + } + @Override public void doStartup(ModuleContext moduleContext) { diff --git a/pipeline/src/org/labkey/pipeline/PipelineModule.java b/pipeline/src/org/labkey/pipeline/PipelineModule.java index 2c692ed9485..a7781c0f7aa 100644 --- a/pipeline/src/org/labkey/pipeline/PipelineModule.java +++ b/pipeline/src/org/labkey/pipeline/PipelineModule.java @@ -339,6 +339,7 @@ public Set getIntegrationTests() PipelineQueueImpl.TestCase.class, PipelineServiceImpl.TestCase.class, StatusController.TestCase.class, + StatusController.ContainerScopingTestCase.class, ClusterStartup.TestCase.class ); } diff --git a/pipeline/src/org/labkey/pipeline/status/StatusController.java b/pipeline/src/org/labkey/pipeline/status/StatusController.java index b167d6bc75b..94965407a05 100644 --- a/pipeline/src/org/labkey/pipeline/status/StatusController.java +++ b/pipeline/src/org/labkey/pipeline/status/StatusController.java @@ -16,7 +16,9 @@ package org.labkey.pipeline.status; +import jakarta.servlet.http.HttpServletResponse; import org.jetbrains.annotations.Nullable; +import org.junit.Test; import org.labkey.api.action.ApiResponse; import org.labkey.api.action.FormHandlerAction; import org.labkey.api.action.FormViewAction; @@ -35,6 +37,7 @@ import org.labkey.api.data.ContainerManager; import org.labkey.api.data.DataRegion; import org.labkey.api.data.DataRegionSelection; +import org.labkey.api.data.Table; import org.labkey.api.pipeline.NoSuchJobException; import org.labkey.api.pipeline.PipeRoot; import org.labkey.api.pipeline.PipelineJob; @@ -51,11 +54,13 @@ 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.AbstractContainerScopingTest; import org.labkey.api.security.permissions.AdminOperationsPermission; import org.labkey.api.security.permissions.DeletePermission; import org.labkey.api.security.permissions.ReadPermission; import org.labkey.api.security.permissions.TroubleshooterPermission; import org.labkey.api.security.permissions.UpdatePermission; +import org.labkey.api.security.roles.ReaderRole; import org.labkey.api.settings.AdminConsole; import org.labkey.api.util.FileUtil; import org.labkey.api.util.HtmlString; @@ -77,6 +82,7 @@ import org.labkey.api.view.template.PageConfig; import org.labkey.pipeline.PipelineController; import org.labkey.pipeline.analysis.AnalysisController; +import org.labkey.pipeline.api.PipelineSchema; import org.labkey.pipeline.api.PipelineServiceImpl; import org.labkey.pipeline.api.PipelineStatusFileImpl; import org.springframework.validation.BindException; @@ -84,6 +90,7 @@ import org.springframework.web.servlet.ModelAndView; import java.io.BufferedReader; +import java.io.File; import java.io.IOException; import java.io.PrintWriter; import java.nio.file.Files; @@ -470,7 +477,7 @@ public Object execute(StatusDetailsForm form, BindException errors) throws Excep Container c = getContainerCheckAdmin(); PipelineStatusFile psf = getStatusFile(form.getRowId()); - if (psf == null) + if (psf == null || !getContainer().equals(psf.lookupContainer())) throw new NotFoundException("Could not find status file for rowId " + form.getRowId()); var status = StatusDetailsBean.create(c, psf, form.getOffset(), form.getCount()); @@ -1076,4 +1083,39 @@ controller.new ForceRefreshAction() ); } } + + public static class ContainerScopingTestCase extends AbstractContainerScopingTest + { + @Test + public void testStatusDetailsContainerScoping() throws Exception + { + User admin = getAdmin(); + Container folderA = createContainer("A"); + Container folderB = createContainer("B"); + User readerA = createUserInRole(folderA, ReaderRole.class); + + // A status file that lives in folder B. FilePath is a required column; point it at a non-existent log so + // StatusDetailsBean skips reading it (it only reads when the file exists) without affecting the scoping check. + PipelineStatusFileImpl sf = new PipelineStatusFileImpl(); + sf.beforeInsert(admin, folderB.getId()); + sf.setStatus(PipelineJob.TaskStatus.complete.toString()); + sf.setFilePath(FileUtil.appendName(FileUtil.getTempDirectory(), "pipeline-scoping-test-" + folderB.getRowId() + ".log").getAbsolutePath()); + sf = Table.insert(admin, PipelineSchema.getInstance().getTableInfoStatusFiles(), sf); + long rowId = sf.getRowId(); + + ActionURL foreignUrl = new ActionURL(StatusDetailsAction.class, folderA).addParameter("rowId", String.valueOf(rowId)); + + // The API is scoped to its own container: addressing B's job through folder A is 404, regardless of the + // caller's rights in B. This is the case that fails without the fix (the unscoped action would serve B's + // job through folder A). + // A caller who can read folder A but NOT folder B: + assertStatus(HttpServletResponse.SC_NOT_FOUND, get(foreignUrl, readerA)); + // ...and a site admin, who CAN read folder B, still gets 404 through folder A (no cross-container redirect). + assertStatus(HttpServletResponse.SC_NOT_FOUND, get(foreignUrl, admin)); + + // Positive control: addressing the job through its own container still succeeds. + ActionURL ownUrl = new ActionURL(StatusDetailsAction.class, folderB).addParameter("rowId", String.valueOf(rowId)); + assertStatus(HttpServletResponse.SC_OK, get(ownUrl, admin)); + } + } } diff --git a/specimen/src/org/labkey/specimen/SpecimenModule.java b/specimen/src/org/labkey/specimen/SpecimenModule.java index 9dbcf3679d0..a83415169e8 100644 --- a/specimen/src/org/labkey/specimen/SpecimenModule.java +++ b/specimen/src/org/labkey/specimen/SpecimenModule.java @@ -72,6 +72,7 @@ import org.labkey.specimen.importer.SpecimenSettingsImporter; import org.labkey.specimen.model.SpecimenRequestEventType; import org.labkey.specimen.pipeline.SpecimenPipeline; +import org.labkey.specimen.requirements.SpecimenRequestRequirementProvider; import org.labkey.specimen.query.SpecimenPivotByDerivativeType; import org.labkey.specimen.query.SpecimenPivotByPrimaryType; import org.labkey.specimen.query.SpecimenPivotByRequestingLocation; @@ -333,7 +334,9 @@ public Set getUnitTests() public @NotNull Set getIntegrationTests() { return Set.of( - SpecimenImporter.TestCase.class + SpecimenImporter.TestCase.class, + SpecimenRequestRequirementProvider.ContainerScopingTestCase.class, + SpecimenController.ContainerScopingTestCase.class ); } } \ No newline at end of file diff --git a/specimen/src/org/labkey/specimen/actions/ShowGroupMembersAction.java b/specimen/src/org/labkey/specimen/actions/ShowGroupMembersAction.java index ddf84246704..f728e50de34 100644 --- a/specimen/src/org/labkey/specimen/actions/ShowGroupMembersAction.java +++ b/specimen/src/org/labkey/specimen/actions/ShowGroupMembersAction.java @@ -92,6 +92,10 @@ public boolean handlePost(UpdateGroupForm form, BindException errors) throws Exc SpecimenRequestActor actor = getActor(form); LocationImpl location = getLocation(form); + // getActor is container-scoped; null means the actorId doesn't belong to this folder + if (actor == null) + throw new NotFoundException(); + if (emailsToDelete != null && emailsToDelete.length > 0) { List invalidEmails = new ArrayList<>(); diff --git a/specimen/src/org/labkey/specimen/actions/SpecimenController.java b/specimen/src/org/labkey/specimen/actions/SpecimenController.java index 0682600a0c2..f3d79169d7e 100644 --- a/specimen/src/org/labkey/specimen/actions/SpecimenController.java +++ b/specimen/src/org/labkey/specimen/actions/SpecimenController.java @@ -14,6 +14,7 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.json.JSONObject; +import org.junit.Test; import org.labkey.api.action.ApiResponse; import org.labkey.api.action.ApiSimpleResponse; import org.labkey.api.action.ExportAction; @@ -82,6 +83,7 @@ import org.labkey.api.security.RequiresPermission; import org.labkey.api.security.User; import org.labkey.api.security.ValidEmail; +import org.labkey.api.security.permissions.AbstractContainerScopingTest; import org.labkey.api.security.permissions.AdminPermission; import org.labkey.api.security.permissions.ReadPermission; import org.labkey.api.security.permissions.UpdatePermission; @@ -4611,7 +4613,8 @@ public boolean handlePost(RequirementForm form, BindException errors) throws Exc { SpecimenRequestRequirement requirement = SpecimenRequestRequirementProvider.get().getRequirement(getContainer(), form.getRequirementId()); - if (requirement.getRequestId() == form.getId()) + // getRequirement is container-scoped; null means the requirementId doesn't belong to this folder + if (requirement != null && requirement.getRequestId() == form.getId()) { SpecimenRequestManager.get().deleteRequestRequirement(getUser(), requirement); return true; @@ -5177,6 +5180,9 @@ public boolean handlePost(EmailSpecimenListForm form, BindException errors) thro ids[i] = Integer.parseInt(idStrs[i]); LocationImpl originatingOrProvidingLocation = LocationManager.get().getLocation(getContainer(), ids[0]); SpecimenRequestActor notifyActor = SpecimenRequestRequirementProvider.get().getActor(getContainer(), ids[1]); + // getActor is container-scoped; null means the actorId doesn't belong to this folder + if (notifyActor == null) + throw new NotFoundException("No notification actor found for id " + ids[1] + " in this folder"); LocationImpl notifyLocation = null; if (notifyActor.isPerSite() && ids[2] >= 0) notifyLocation = LocationManager.get().getLocation(getContainer(), ids[2]); @@ -5757,4 +5763,89 @@ public void addNavTrail(NavTree root) root.addChild("Insert " + _form.getQueryName()); } } + + /** + * Verifies that actions resolving a specimen object by its global rowId reject ids that belong to a different + * container, even when the caller has the action's required permission in the current folder. These exercise the + * action-level guards that depend on the container scoping enforced by {@link SpecimenRequestRequirementProvider}. + */ + public static class ContainerScopingTestCase extends AbstractContainerScopingTest + { + // Actors are required by requirements through a NOT NULL foreign key, so several fixtures need one + private SpecimenRequestActor createActor(Container c, String label) + { + SpecimenRequestActor actor = new SpecimenRequestActor(); + actor.setContainer(c); + actor.setLabel(label); + return actor.create(getAdmin()); + } + + @Test + public void testShowGroupMembersActionRejectsForeignActor() throws Exception + { + Container folderA = createContainer("A"); + Container folderB = createContainer("B"); + + // An actor that lives in folder A + SpecimenRequestActor actor = createActor(folderA, "Group members scoping actor"); + int actorId = actor.getRowId(); + + // Addressing the action through folder B, where the actor does not live, must 404 rather than expose it + ActionURL foreignUrl = new ActionURL(ShowGroupMembersAction.class, folderB).addParameter("id", String.valueOf(actorId)); + assertStatus(HttpServletResponse.SC_NOT_FOUND, post(foreignUrl, getAdmin())); + + // Positive control: the same request through the actor's own folder is accepted (redirect on success), + // proving the guard rejects only the cross-container case rather than every request + ActionURL ownUrl = new ActionURL(ShowGroupMembersAction.class, folderA).addParameter("id", String.valueOf(actorId)); + assertStatus(HttpServletResponse.SC_FOUND, post(ownUrl, getAdmin())); + } + + @Test + public void testDeleteRequirementActionRejectsForeignRequirement() throws Exception + { + Container folderA = createContainer("A"); + Container folderB = createContainer("B"); + SpecimenRequestRequirementProvider provider = SpecimenRequestRequirementProvider.get(); + + // Build a real request chain in folder A: status -> request -> requirement. Deleting a requirement logs a + // request event whose RequestId is a foreign key into SampleRequest, so the request row must actually + // exist (a fake id would fail the FK and mask what this test is checking). Capture each insert's return so + // we have the generated rowId. + SpecimenRequestStatus status = new SpecimenRequestStatus(); + status.setContainer(folderA); + status.setLabel("Delete requirement scoping status"); + status.setSortOrder(1); // non-null and >= 0 so the bean isn't treated as a system status + status = Table.insert(getAdmin(), SpecimenSchema.get().getTableInfoSampleRequestStatus(), status); + + SpecimenRequest request = new SpecimenRequest(); + request.setContainer(folderA); + request.setStatusId(status.getRowId()); + request = SpecimenRequestManager.get().createRequest(getAdmin(), request, false); + int requestId = request.getRowId(); + + SpecimenRequestActor actor = createActor(folderA, "Delete requirement scoping actor"); + SpecimenRequestRequirement requirement = new SpecimenRequestRequirement(); + requirement.setContainer(folderA); + requirement.setRequestId(requestId); + requirement.setActorId(actor.getRowId()); + requirement.setDescription("Delete requirement scoping test"); + requirement = requirement.persist(getAdmin(), GUID.makeGUID()); + int requirementId = requirement.getRowId(); + + // Deleting through folder B, where the requirement does not live, must be a no-op: the row survives + ActionURL foreignUrl = new ActionURL(DeleteRequirementAction.class, folderB) + .addParameter("id", String.valueOf(requestId)) + .addParameter("requirementId", String.valueOf(requirementId)); + post(foreignUrl, getAdmin()); + assertNotNull("Cross-container delete must not remove the requirement", provider.getRequirement(folderA, requirementId)); + + // Positive control: deleting through the requirement's own folder removes it, proving the guard rejects + // only the cross-container case rather than every delete + ActionURL ownUrl = new ActionURL(DeleteRequirementAction.class, folderA) + .addParameter("id", String.valueOf(requestId)) + .addParameter("requirementId", String.valueOf(requirementId)); + post(ownUrl, getAdmin()); + assertNull("Same-container delete should remove the requirement", provider.getRequirement(folderA, requirementId)); + } + } } diff --git a/specimen/src/org/labkey/specimen/requirements/DefaultRequirementProvider.java b/specimen/src/org/labkey/specimen/requirements/DefaultRequirementProvider.java index 803e98fd4b5..2111948c60c 100644 --- a/specimen/src/org/labkey/specimen/requirements/DefaultRequirementProvider.java +++ b/specimen/src/org/labkey/specimen/requirements/DefaultRequirementProvider.java @@ -100,7 +100,11 @@ private synchronized String getDefaultRequirementPlaceholder(final Container con @Override public R getRequirement(Container container, Object requirementPrimaryKey) { - return new TableSelector(getRequirementTableInfo()).getObject(requirementPrimaryKey, _requirementClass); + R requirement = new TableSelector(getRequirementTableInfo()).getObject(requirementPrimaryKey, _requirementClass); + // The lookup is by global primary key; reject rows that don't belong to the requested container + if (requirement != null && !container.equals(requirement.getContainer())) + return null; + return requirement; } public R[] getRequirements(Container container, String ownerEntityId) @@ -133,7 +137,11 @@ public A[] getActors(Container c) @Override public A getActor(Container c, Object primaryKey) { - return new TableSelector(getActorTableInfo()).getObject(primaryKey, _actorClass); + A actor = new TableSelector(getActorTableInfo()).getObject(primaryKey, _actorClass); + // The lookup is by global primary key; reject rows that don't belong to the requested container + if (actor != null && !c.equals(actor.getContainer())) + return null; + return actor; } @Override diff --git a/specimen/src/org/labkey/specimen/requirements/SpecimenRequestRequirementProvider.java b/specimen/src/org/labkey/specimen/requirements/SpecimenRequestRequirementProvider.java index 4af4b5ab4d2..c7be11bb57c 100644 --- a/specimen/src/org/labkey/specimen/requirements/SpecimenRequestRequirementProvider.java +++ b/specimen/src/org/labkey/specimen/requirements/SpecimenRequestRequirementProvider.java @@ -16,9 +16,12 @@ package org.labkey.specimen.requirements; +import org.junit.Test; import org.labkey.api.data.Container; import org.labkey.api.data.TableInfo; +import org.labkey.api.security.permissions.AbstractContainerScopingTest; import org.labkey.api.specimen.SpecimenSchema; +import org.labkey.api.util.GUID; import org.labkey.specimen.model.SpecimenRequestActor; import java.util.Collection; @@ -99,4 +102,49 @@ public Set getActorsInUseSet(Container container) ids.add(actor.getRowId()); return ids; } + + public static class ContainerScopingTestCase extends AbstractContainerScopingTest + { + @Test + public void testGetActorContainerScoping() + { + Container folderA = createContainer("A"); + Container folderB = createContainer("B"); + SpecimenRequestRequirementProvider provider = SpecimenRequestRequirementProvider.get(); + + SpecimenRequestActor actor = new SpecimenRequestActor(); + actor.setContainer(folderA); + actor.setLabel("Scoping test actor"); + actor = actor.create(getAdmin()); + int rowId = actor.getRowId(); + + assertNotNull("Actor should be visible from its own container", provider.getActor(folderA, rowId)); + assertNull("Actor must NOT be visible from another container", provider.getActor(folderB, rowId)); + } + + @Test + public void testGetRequirementContainerScoping() + { + Container folderA = createContainer("A"); + Container folderB = createContainer("B"); + SpecimenRequestRequirementProvider provider = SpecimenRequestRequirementProvider.get(); + + // A requirement references an actor through a NOT NULL foreign key, so create one in the same folder first + SpecimenRequestActor actor = new SpecimenRequestActor(); + actor.setContainer(folderA); + actor.setLabel("Requirement scoping test actor"); + actor = actor.create(getAdmin()); + + SpecimenRequestRequirement requirement = new SpecimenRequestRequirement(); + requirement.setContainer(folderA); + requirement.setRequestId(-1); // no real request row is needed for a primary-key lookup + requirement.setActorId(actor.getRowId()); + requirement.setDescription("Requirement scoping test"); + requirement = requirement.persist(getAdmin(), GUID.makeGUID()); + int rowId = requirement.getRowId(); + + assertNotNull("Requirement should be visible from its own container", provider.getRequirement(folderA, rowId)); + assertNull("Requirement must NOT be visible from another container", provider.getRequirement(folderB, rowId)); + } + } } diff --git a/study/src/org/labkey/study/StudyModule.java b/study/src/org/labkey/study/StudyModule.java index c5a62276bfa..60bb3b9d968 100644 --- a/study/src/org/labkey/study/StudyModule.java +++ b/study/src/org/labkey/study/StudyModule.java @@ -717,7 +717,8 @@ public Set getIntegrationTests() StudyModule.TestCase.class, VisitImpl.TestCase.class, DatasetUpdateService.TestCase.class, - DatasetLsidImportHelper.TestCase.class); + DatasetLsidImportHelper.TestCase.class, + org.labkey.study.controllers.CreateChildStudyAction.ContainerScopingTestCase.class); } @Override diff --git a/study/src/org/labkey/study/controllers/CreateChildStudyAction.java b/study/src/org/labkey/study/controllers/CreateChildStudyAction.java index 9e502bf6a30..4f626b5d595 100644 --- a/study/src/org/labkey/study/controllers/CreateChildStudyAction.java +++ b/study/src/org/labkey/study/controllers/CreateChildStudyAction.java @@ -15,7 +15,10 @@ */ package org.labkey.study.controllers; +import jakarta.servlet.http.HttpServletResponse; import org.jetbrains.annotations.NotNull; +import org.junit.Before; +import org.junit.Test; import org.labkey.api.action.ApiResponse; import org.labkey.api.action.ApiSimpleResponse; import org.labkey.api.action.MutatingApiAction; @@ -31,19 +34,25 @@ import org.labkey.api.query.ValidationException; import org.labkey.api.security.RequiresPermission; import org.labkey.api.security.User; +import org.labkey.api.security.permissions.AbstractContainerScopingTest; import org.labkey.api.security.permissions.AdminPermission; +import org.labkey.api.security.permissions.ReadPermission; +import org.labkey.api.security.roles.FolderAdminRole; import org.labkey.api.specimen.SpecimenSchema; import org.labkey.api.specimen.importer.ImportTemplate; import org.labkey.api.study.SpecimenTablesTemplate; import org.labkey.api.study.Study; +import org.labkey.api.study.StudySnapshotType; import org.labkey.api.study.TimepointType; import org.labkey.api.util.PageFlowUtil; import org.labkey.api.util.Path; +import org.labkey.api.view.ActionURL; import org.labkey.study.StudyFolderType; import org.labkey.study.importer.CreateChildStudyPipelineJob; import org.labkey.study.model.ChildStudyDefinition; import org.labkey.study.model.StudyImpl; import org.labkey.study.model.StudyManager; +import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.validation.BindException; import org.springframework.validation.Errors; import org.springframework.validation.ObjectError; @@ -101,27 +110,54 @@ public ApiResponse execute(ChildStudyDefinition form, BindException errors) thro @Override public void validateForm(ChildStudyDefinition form, Errors errors) { - Container c = ContainerManager.getForPath(form.getDstPath()); - _destFolderCreated = c == null; + // Verify the user can read the source study and can administer the destination (or its parent, if the + // folder must be created) before we create any folder or queue the copy job. + Container sourceContainer = form.getSrcPath() != null ? ContainerManager.getForPath(form.getSrcPath()) : null; + if (sourceContainer == null || !sourceContainer.hasPermission(getUser(), ReadPermission.class)) + { + errors.reject(SpringActionController.ERROR_MSG, "Unable to locate the parent study from location : " + form.getSrcPath()); + } + else + { + _sourceStudy = StudyManager.getInstance().getStudy(sourceContainer); + if (_sourceStudy == null) + errors.reject(SpringActionController.ERROR_MSG, "Unable to locate the parent study from location : " + form.getSrcPath()); + } + + Container existingDst = form.getDstPath() != null ? ContainerManager.getForPath(form.getDstPath()) : null; + _destFolderCreated = existingDst == null; - // make sure the folder, if already existing doesn't already contain a study - _dstContainer = ContainerManager.ensureContainer(Path.parse(form.getDstPath()), getUser()); - if (_dstContainer != null) + boolean dstAuthorized; + if (existingDst != null) { + // Existing destination folder: require Admin on it. + dstAuthorized = existingDst.hasPermission(getUser(), AdminPermission.class); + } + else if (form.getDstPath() != null) + { + // Folder will be created: require Admin on the parent so an arbitrary folder can't be grafted in. + Container dstParent = ContainerManager.getForPath(Path.parse(form.getDstPath()).getParent()); + dstAuthorized = dstParent != null && dstParent.hasPermission(getUser(), AdminPermission.class); + } + else + { + dstAuthorized = false; + } + + if (!dstAuthorized) + { + errors.reject(SpringActionController.ERROR_MSG, "Invalid destination folder."); + } + // Only create the destination folder once the rest of the form (including the source-read check above) has + // validated, so a rejected publish doesn't leave a stray empty folder behind. + else if (!errors.hasErrors()) + { + // make sure the folder, if already existing doesn't already contain a study + _dstContainer = ContainerManager.ensureContainer(Path.parse(form.getDstPath()), getUser()); Study study = StudyManager.getInstance().getStudy(_dstContainer); if (study != null) - { errors.reject(SpringActionController.ERROR_MSG, "A study already exists in the destination folder."); - } } - else - errors.reject(SpringActionController.ERROR_MSG, "Invalid destination folder."); - - Container sourceContainer = ContainerManager.getForPath(form.getSrcPath()); - _sourceStudy = StudyManager.getInstance().getStudy(sourceContainer); - - if (_sourceStudy == null) - errors.reject(SpringActionController.ERROR_MSG, "Unable to locate the parent study from location : " + form.getSrcPath()); if (form.getMode() == null) errors.reject(SpringActionController.ERROR_MSG, "Unable to locate a study snapshot type from specified mode"); @@ -178,4 +214,72 @@ private StudyImpl createNewStudy(ChildStudyDefinition form, BindException errors return study; } + + public static class ContainerScopingTestCase extends AbstractContainerScopingTest + { + private Container _source; + + @Before + public void createSourceStudy() + { + // A real study in the source folder, owned by the site admin + _source = createContainer("Source"); + StudyImpl study = new StudyImpl(_source, "Source Study"); + study.setTimepointType(TimepointType.VISIT); + StudyManager.getInstance().createTestStudy(getAdmin(), study); + } + + @Test + public void testPublishRequiresSourceRead() throws Exception + { + // A user who is a folder admin in the destination only (no rights in the source) + Container dest = createContainer("Dest"); + User destAdminOnly = createUserInRole(dest, FolderAdminRole.class); + + MockHttpServletResponse resp = publish(dest, dest.getPath(), destAdminOnly); + + // The publish must not succeed because the user can't read the source study + assertNotEquals("Publish from an unreadable source study must not succeed", HttpServletResponse.SC_OK, resp.getStatus()); + assertNull("No study should have been created in the destination", StudyManager.getInstance().getStudy(dest)); + assertSourceUntouched(); + + // Positive scenario covered by StudyPublishTest + } + + @Test + public void testPublishToNewFolderRequiresParentAdmin() throws Exception + { + // A user who is a folder admin in the source only + Container parent = createContainer("Parent"); + User sourceAdminOnly = createUserInRole(_source, FolderAdminRole.class); + + // The destination folder does not exist yet, so authorization falls to the dstParent branch + String newDstPath = parent.getPath() + "/PublishedChild"; + MockHttpServletResponse resp = publish(_source, newDstPath, sourceAdminOnly); + + // The publish must not succeed because the user can't administer the destination's parent + assertNotEquals("Publishing into a new folder under an unadministered parent must not succeed", HttpServletResponse.SC_OK, resp.getStatus()); + // The guard runs before ensureContainer(), so no destination folder must have been grafted in + assertNull("No destination folder should have been created under the parent", ContainerManager.getForPath(Path.parse(newDstPath))); + assertSourceUntouched(); + + // Positive scenario (a parent admin creating the child folder) covered by StudyPublishTest + } + + // Posts a publish of _source into dstPath, dispatched through requestContainer (which is the container the + // action's @RequiresPermission is evaluated against). + private MockHttpServletResponse publish(Container requestContainer, String dstPath, User user) throws Exception + { + ActionURL url = new ActionURL(CreateChildStudyAction.class, requestContainer) + .addParameter("srcPath", _source.getPath()) + .addParameter("dstPath", dstPath) + .addParameter("mode", StudySnapshotType.publish.name()); + return post(url, user); + } + + private void assertSourceUntouched() + { + assertNotNull("The source study must be untouched", StudyManager.getInstance().getStudy(_source)); + } + } } diff --git a/study/src/org/labkey/study/model/DatasetImportTestCase.jsp b/study/src/org/labkey/study/model/DatasetImportTestCase.jsp index c6c58c6570c..7cdb76bc5dc 100644 --- a/study/src/org/labkey/study/model/DatasetImportTestCase.jsp +++ b/study/src/org/labkey/study/model/DatasetImportTestCase.jsp @@ -97,13 +97,9 @@ public void createStudy() c.setFolderType(FolderTypeManager.get().getFolderType(StudyFolderType.NAME), _context.getUser()); StudyImpl s = new StudyImpl(c, this.getClass().getName()); s.setTimepointType(TimepointType.DATE); - s.setStartDate(new Date(DateUtil.parseDateTime(c, "2001-01-01"))); - s.setSubjectColumnName("SubjectID"); - s.setSubjectNounPlural("Subjects"); - s.setSubjectNounSingular("Subject"); s.setSecurityType(SecurityType.BASIC_WRITE); - s.setStartDate(new Date(DateUtil.parseDateTime(c, "1 Jan 2000"))); - _studyDateBased = StudyManager.getInstance().createStudy(_context.getUser(), s); + s.setStartDate(new Date(DateUtil.parseDateTime("1 Jan 2000"))); + _studyDateBased = StudyManager.getInstance().createTestStudy(_context.getUser(), s); MvUtil.assignMvIndicators(c, new String[] {"X", "Y", "Z"}, @@ -115,12 +111,9 @@ public void createStudy() Container c = ContainerManager.createContainer(junit, name, _context.getUser()); StudyImpl s = new StudyImpl(c, "Junit Study"); s.setTimepointType(TimepointType.VISIT); - s.setStartDate(new Date(DateUtil.parseDateTime(c, "2001-01-01"))); - s.setSubjectColumnName("SubjectID"); - s.setSubjectNounPlural("Subjects"); - s.setSubjectNounSingular("Subject"); + s.setStartDate(new Date(DateUtil.parseDateTime("2001-01-01"))); s.setSecurityType(SecurityType.BASIC_WRITE); - _studyVisitBased = StudyManager.getInstance().createStudy(_context.getUser(), s); + _studyVisitBased = StudyManager.getInstance().createTestStudy(_context.getUser(), s); MvUtil.assignMvIndicators(c, new String[] {"X", "Y", "Z"}, diff --git a/study/src/org/labkey/study/model/DatasetLsidImportHelper.java b/study/src/org/labkey/study/model/DatasetLsidImportHelper.java index 57c23240057..053af1a7652 100644 --- a/study/src/org/labkey/study/model/DatasetLsidImportHelper.java +++ b/study/src/org/labkey/study/model/DatasetLsidImportHelper.java @@ -150,12 +150,9 @@ private StudyImpl createStudy(TimepointType timepointType, String name) Container c = ContainerManager.createContainer(JunitUtil.getTestContainer(), GUID.makeHash(), context.getUser()); StudyImpl study = new StudyImpl(c, name); study.setTimepointType(timepointType); - study.setStartDate(new Date(DateUtil.parseDateTime(c, "2025-04-01"))); - study.setSubjectColumnName("SubjectID"); - study.setSubjectNounPlural("Subjects"); - study.setSubjectNounSingular("Subject"); + study.setStartDate(new Date(DateUtil.parseDateTime("2025-04-01"))); - return StudyManager.getInstance().createStudy(context.getUser(), study); + return StudyManager.getInstance().createTestStudy(context.getUser(), study); } @After diff --git a/study/src/org/labkey/study/model/StudyImpl.java b/study/src/org/labkey/study/model/StudyImpl.java index eabc4999273..4b60f1abb6e 100644 --- a/study/src/org/labkey/study/model/StudyImpl.java +++ b/study/src/org/labkey/study/model/StudyImpl.java @@ -1262,13 +1262,9 @@ public void createStudy() Container c = ContainerManager.createContainer(junit, name, _context.getUser()); StudyImpl s = new StudyImpl(c, "Junit Study"); s.setTimepointType(TimepointType.DATE); - s.setStartDate(new Date(DateUtil.parseISODateTime("2001-01-01"))); - s.setSubjectColumnName("SubjectID"); - s.setSubjectNounPlural("Subjects"); - s.setSubjectNounSingular("Subject"); s.setSecurityType(SecurityType.BASIC_WRITE); - s.setStartDate(new Date(DateUtil.parseDateTime(c, "1 Jan 2000"))); - _testStudy = StudyManager.getInstance().createStudy(_context.getUser(), s); + s.setStartDate(new Date(DateUtil.parseDateTime("1 Jan 2000"))); + _testStudy = StudyManager.getInstance().createTestStudy(_context.getUser(), s); MvUtil.assignMvIndicators(c, new String[]{"X", "Y", "Z"}, diff --git a/study/src/org/labkey/study/model/StudyManager.java b/study/src/org/labkey/study/model/StudyManager.java index 2b38596364c..246a7a9e733 100644 --- a/study/src/org/labkey/study/model/StudyManager.java +++ b/study/src/org/labkey/study/model/StudyManager.java @@ -570,6 +570,19 @@ public Set getAllStudies(@NotNull Container root, @NotNull return Collections.unmodifiableSet(result); } + /** Helper to populate a test study with reasonable defaults */ + public StudyImpl createTestStudy(User user, StudyImpl study) + { + if (StringUtils.isBlank(study.getSubjectNounSingular())) + study.setSubjectNounSingular("Subject"); + if (StringUtils.isBlank(study.getSubjectNounPlural())) + study.setSubjectNounPlural("Subjects"); + if (StringUtils.isBlank(study.getSubjectColumnName())) + study.setSubjectColumnName("SubjectID"); + + return createStudy(user, study); + } + public StudyImpl createStudy(User user, StudyImpl study) { Container container = study.getContainer(); diff --git a/study/src/org/labkey/study/query/DatasetUpdateService.java b/study/src/org/labkey/study/query/DatasetUpdateService.java index 496212b30f0..50b2a6056d9 100644 --- a/study/src/org/labkey/study/query/DatasetUpdateService.java +++ b/study/src/org/labkey/study/query/DatasetUpdateService.java @@ -1113,12 +1113,9 @@ public void createStudy() MvUtil.assignMvIndicators(c, new String[] {"NA","QA"}, new String[] {"NA","QA"}); StudyImpl s = new StudyImpl(c, "Junit Study"); s.setTimepointType(TimepointType.VISIT); - s.setStartDate(new Date(DateUtil.parseDateTime(c, "2014-01-01"))); - s.setSubjectColumnName(SUBJECT_COLUMN_NAME); - s.setSubjectNounPlural("Subjects"); - s.setSubjectNounSingular("Subject"); + s.setStartDate(new Date(DateUtil.parseDateTime("2014-01-01"))); s.setSecurityType(SecurityType.BASIC_WRITE); - _junitStudy = StudyManager.getInstance().createStudy(_context.getUser(), s); + _junitStudy = StudyManager.getInstance().createTestStudy(_context.getUser(), s); _user = _context.getUser(); _container = _junitStudy.getContainer(); } diff --git a/survey/src/org/labkey/survey/SurveyController.java b/survey/src/org/labkey/survey/SurveyController.java index 3f78d0406ea..121d1a28ecf 100644 --- a/survey/src/org/labkey/survey/SurveyController.java +++ b/survey/src/org/labkey/survey/SurveyController.java @@ -70,6 +70,7 @@ import org.labkey.api.view.ActionURL; import org.labkey.api.view.JspView; import org.labkey.api.view.NavTree; +import org.labkey.api.view.NotFoundException; import org.springframework.validation.BindException; import org.springframework.validation.Errors; import org.springframework.web.servlet.ModelAndView; @@ -420,12 +421,19 @@ private SurveyDesign getSurveyDesign(SurveyDesignForm form) { SurveyDesign survey = new SurveyDesign(); if (form.getRowId() != 0) + { survey = SurveyManager.get().getSurveyDesign(getContainer(), getUser(), form.getRowId()); + // getSurveyDesign is container-scoped; null here means the rowId doesn't belong to this folder + if (survey == null) + throw new NotFoundException("No survey design found for rowId " + form.getRowId() + " in this folder"); + } else if (form.getDesignId() != null) { if (NumberUtils.isDigits(form.getDesignId())) { survey = SurveyManager.get().getSurveyDesign(getContainer(), getUser(), NumberUtils.toInt(form.getDesignId())); + if (survey == null) + throw new NotFoundException("No survey design found for designId " + form.getDesignId() + " in this folder"); } else { @@ -455,7 +463,12 @@ private Survey getSurvey(SurveyForm form) { Survey survey = new Survey(); if (form.getRowId() != null) + { survey = SurveyManager.get().getSurvey(getContainer(), getUser(), form.getRowId()); + // getSurvey is container-scoped; null here means the rowId doesn't belong to this folder + if (survey == null) + throw new NotFoundException("No survey found for rowId " + form.getRowId() + " in this folder"); + } if (survey != null) { diff --git a/survey/src/org/labkey/survey/SurveyManager.java b/survey/src/org/labkey/survey/SurveyManager.java index 2d35c13b839..66be29ef8b0 100644 --- a/survey/src/org/labkey/survey/SurveyManager.java +++ b/survey/src/org/labkey/survey/SurveyManager.java @@ -24,6 +24,7 @@ import org.json.JSONException; import org.json.JSONObject; import org.junit.Assert; +import org.junit.Before; import org.junit.Test; import org.labkey.api.action.NullSafeBindException; import org.labkey.api.collections.CaseInsensitiveHashMap; @@ -61,6 +62,7 @@ import org.labkey.api.query.UserSchema; import org.labkey.api.resource.Resource; import org.labkey.api.security.User; +import org.labkey.api.security.permissions.AbstractContainerScopingTest; import org.labkey.api.survey.model.Survey; import org.labkey.api.survey.model.SurveyDesign; import org.labkey.api.survey.model.SurveyListener; @@ -264,9 +266,13 @@ public Survey saveSurvey(Container container, User user, Survey survey) } } + @Nullable public SurveyDesign getSurveyDesign(Container container, User user, int surveyId) { - return new TableSelector(SurveySchema.getInstance().getSurveyDesignsTable(), new SimpleFilter(FieldKey.fromParts("rowId"), surveyId), null).getObject(SurveyDesign.class); + // Scope by container + SimpleFilter filter = new SimpleFilter(FieldKey.fromParts("rowId"), surveyId); + filter.addCondition(FieldKey.fromParts("Container"), container); + return new TableSelector(SurveySchema.getInstance().getSurveyDesignsTable(), filter, null).getObject(SurveyDesign.class); } /** @@ -313,8 +319,10 @@ public SurveyDesign[] getSurveyDesigns(SimpleFilter filter) public Survey getSurvey(Container container, User user, int rowId) { + // Scope by container so a global rowId can't read/modify a survey in another folder SimpleFilter filter = new SimpleFilter(); filter.addCondition(FieldKey.fromParts("rowId"), rowId); + filter.addCondition(FieldKey.fromParts("Container"), container); return new TableSelector(SurveySchema.getInstance().getSurveysTable(), filter, null).getObject(Survey.class); } @@ -381,7 +389,7 @@ public void deleteSurveyDesign(Container c, User user, int surveyDesignId, boole deleteSurvey(c, user, survey.getRowId()); } SQLFragment deleteSurveyDesignsSql = new SQLFragment("DELETE FROM "); - deleteSurveyDesignsSql.append(s.getSurveyDesignsTable()).append(" WHERE RowId = ?").add(surveyDesignId); + deleteSurveyDesignsSql.append(s.getSurveyDesignsTable()).append(" WHERE RowId = ? AND Container = ?").add(surveyDesignId).add(c); executor.execute(deleteSurveyDesignsSql); transaction.commit(); @@ -390,8 +398,10 @@ public void deleteSurveyDesign(Container c, User user, int surveyDesignId, boole public Survey[] getSurveys(Container c, User user, int surveyDesignId) { + // Scope by container so the delete cascade and lookups can't reach surveys in another folder SimpleFilter filter = new SimpleFilter(); filter.addCondition(FieldKey.fromParts("surveyDesignId"), surveyDesignId); + filter.addCondition(FieldKey.fromParts("Container"), c); return new TableSelector(SurveySchema.getInstance().getSurveysTable(), filter, null).getArray(Survey.class); } @@ -782,4 +792,69 @@ public void testGetMetaDataForColumn() assertTrue("Unexpected property value", trimmedMap.get("required").equals(true)); } } + + public static class ContainerScopingTestCase extends AbstractContainerScopingTest + { + private Container _projectA; + private Container _projectB; + private User _user; + + @Before + public void setUp() + { + _user = getAdmin(); + _projectA = createContainer("A"); + _projectB = createContainer("B"); + } + + @Test + public void testSurveyDesignContainerScoping() + { + SurveyManager sm = SurveyManager.get(); + + SurveyDesign design = new SurveyDesign(); + design.setLabel("Scoping test design"); + design = sm.saveSurveyDesign(_projectA, _user, design); + int designId = design.getRowId(); + + // Same-container lookup succeeds; cross-container lookup must return null + assertNotNull("Design should be visible from its own container", sm.getSurveyDesign(_projectA, _user, designId)); + assertNull("Design must NOT be visible from another container", sm.getSurveyDesign(_projectB, _user, designId)); + + // A delete issued from the wrong container must not remove the design + sm.deleteSurveyDesign(_projectB, _user, designId, true); + assertNotNull("Cross-container delete must be a no-op", sm.getSurveyDesign(_projectA, _user, designId)); + + // A delete from the correct container removes it + sm.deleteSurveyDesign(_projectA, _user, designId, true); + assertNull("Same-container delete should remove the design", sm.getSurveyDesign(_projectA, _user, designId)); + } + + @Test + public void testSurveyContainerScoping() + { + SurveyManager sm = SurveyManager.get(); + + SurveyDesign design = new SurveyDesign(); + design.setLabel("Scoping test design for survey"); + design = sm.saveSurveyDesign(_projectA, _user, design); + + Survey survey = new Survey(); + survey.setLabel("Scoping test survey"); + survey.setSurveyDesignId(design.getRowId()); + survey = sm.saveSurvey(_projectA, _user, survey); + int surveyRowId = survey.getRowId(); + + assertNotNull("Survey should be visible from its own container", sm.getSurvey(_projectA, _user, surveyRowId)); + assertNull("Survey must NOT be visible from another container", sm.getSurvey(_projectB, _user, surveyRowId)); + + // A delete issued from the wrong container must not remove the survey + sm.deleteSurvey(_projectB, _user, surveyRowId); + assertNotNull("Cross-container delete must be a no-op", sm.getSurvey(_projectA, _user, surveyRowId)); + + // A delete from the correct container removes it + sm.deleteSurvey(_projectA, _user, surveyRowId); + assertNull("Same-container delete should remove the survey", sm.getSurvey(_projectA, _user, surveyRowId)); + } + } } diff --git a/survey/src/org/labkey/survey/SurveyModule.java b/survey/src/org/labkey/survey/SurveyModule.java index a55dd4d8cf9..96ef0d24dc4 100644 --- a/survey/src/org/labkey/survey/SurveyModule.java +++ b/survey/src/org/labkey/survey/SurveyModule.java @@ -270,4 +270,12 @@ public Set getUnitTests() SurveyManager.TestCase.class ); } + + @Override + public @NotNull Set getIntegrationTests() + { + return Set.of( + SurveyManager.ContainerScopingTestCase.class + ); + } } diff --git a/wiki/src/org/labkey/wiki/WikiController.java b/wiki/src/org/labkey/wiki/WikiController.java index 850acac6852..0525ecec9d7 100644 --- a/wiki/src/org/labkey/wiki/WikiController.java +++ b/wiki/src/org/labkey/wiki/WikiController.java @@ -17,6 +17,7 @@ package org.labkey.wiki; import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletResponse; import org.apache.commons.collections4.MultiValuedMap; import org.apache.commons.collections4.multimap.ArrayListValuedHashMap; import org.apache.commons.lang3.StringUtils; @@ -24,6 +25,8 @@ import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.junit.Before; +import org.junit.Test; import org.labkey.api.action.ApiResponse; import org.labkey.api.action.ApiSimpleResponse; import org.labkey.api.action.ConfirmAction; @@ -56,8 +59,10 @@ import org.labkey.api.security.User; import org.labkey.api.security.UserManager; import org.labkey.api.security.WikiTermsOfUseProvider; +import org.labkey.api.security.permissions.AbstractContainerScopingTest; import org.labkey.api.security.permissions.AdminPermission; import org.labkey.api.security.permissions.ReadPermission; +import org.labkey.api.security.roles.FolderAdminRole; import org.labkey.api.settings.AdminConsole; import org.labkey.api.settings.AppProps; import org.labkey.api.util.GUID; @@ -989,6 +994,11 @@ public boolean handlePost(CopyWikiForm form, BindException errors) throws Except if (errors.hasErrors()) return false; + if (cSrc == null || !cSrc.hasPermission(getUser(), AdminPermission.class)) + throw new NotFoundException("No source container found, or you do not have permission to copy from it."); + if (_cDest == null || !_cDest.hasPermission(getUser(), AdminPermission.class)) + throw new NotFoundException("No destination container found, or you do not have permission to copy to it."); + if (cSrc.equals(_cDest)) { throw new NotFoundException("Cannot copy a wiki into the folder it is being copied from."); @@ -1007,37 +1017,34 @@ public boolean handlePost(CopyWikiForm form, BindException errors) throws Except throw new NotFoundException("No page named '" + pageName + "' exists in the source container."); } - if (_cDest != null && _cDest.hasPermission(getUser(), AdminPermission.class)) - { - //get source wiki pages - List srcPageNames; + //get source wiki pages + List srcPageNames; - if (parentPage != null) - // TODO: make subtrees work; previously getWikiManager().getSubTreePageList(cSrc, parentPage), now - // something like WikiSelectManager.getDescendents(cSrc, name) - srcPageNames = WikiSelectManager.getPageNames(cSrc); - else - srcPageNames = WikiSelectManager.getPageNames(cSrc); + if (parentPage != null) + // TODO: make subtrees work; previously getWikiManager().getSubTreePageList(cSrc, parentPage), now + // something like WikiSelectManager.getDescendents(cSrc, name) + srcPageNames = WikiSelectManager.getPageNames(cSrc); + else + srcPageNames = WikiSelectManager.getPageNames(cSrc); - //get existing destination wiki page names - List destPageNames = WikiSelectManager.getPageNames(_cDest); + //get existing destination wiki page names + List destPageNames = WikiSelectManager.getPageNames(_cDest); - //map source page row ids to new page row ids - Map pageIdMap = new HashMap<>(); - //shortcut for root topics - pageIdMap.put(null, null); + //map source page row ids to new page row ids + Map pageIdMap = new HashMap<>(); + //shortcut for root topics + pageIdMap.put(null, null); - //copy each page in the list - for (String name : srcPageNames) - { - Wiki srcWikiPage = WikiSelectManager.getWiki(cSrc, name); - getWikiManager().copyPage(getUser(), cSrc, srcWikiPage, _cDest, destPageNames, pageIdMap, form.getIsCopyingHistory()); - } - - //display the wiki module in the destination container - displayWikiModuleInDestContainer(_cDest); + //copy each page in the list + for (String name : srcPageNames) + { + Wiki srcWikiPage = WikiSelectManager.getWiki(cSrc, name); + getWikiManager().copyPage(getUser(), cSrc, srcWikiPage, _cDest, destPageNames, pageIdMap, form.getIsCopyingHistory()); } + //display the wiki module in the destination container + displayWikiModuleInDestContainer(_cDest); + return true; } } @@ -2922,4 +2929,32 @@ private WikiManager getWikiManager() { return WikiManager.get(); } + + public static class CopyWikiContainerScopingTestCase extends AbstractContainerScopingTest + { + @Test + public void testCopyWikiRequiresSourceAdmin() throws Exception + { + Container dest = createContainer("Dest"); + Container source = createContainer("Source"); + + // A user who is a folder admin in the destination only (no rights in the source) + User destAdminOnly = createUserInRole(dest, FolderAdminRole.class); + + // A wiki page that lives in the source folder + WikiManager.get().insertWiki(getAdmin(), source, "secretPage", "secret body", WikiRendererType.HTML, "Secret Page"); + + ActionURL url = new ActionURL(CopyWikiAction.class, dest) + .addParameter("sourceContainer", source.getPath()) + .addParameter("destContainer", dest.getPath()); + assertStatus(HttpServletResponse.SC_NOT_FOUND, post(url, destAdminOnly)); + assertTrue("No pages should have been copied into the destination", WikiSelectManager.getPageNames(dest).isEmpty()); + + // Positive control: the same copy by a user who is admin on BOTH folders is accepted (redirect to the + // destination) and actually copies the page, proving the guard rejects only the cross-container case + // rather than every copy. + assertStatus(HttpServletResponse.SC_FOUND, post(url, getAdmin())); + assertFalse("Admin copy from a readable source should have copied the wiki page", WikiSelectManager.getPageNames(dest).isEmpty()); + } + } } diff --git a/wiki/src/org/labkey/wiki/WikiModule.java b/wiki/src/org/labkey/wiki/WikiModule.java index 096fd62a081..244815c51ea 100644 --- a/wiki/src/org/labkey/wiki/WikiModule.java +++ b/wiki/src/org/labkey/wiki/WikiModule.java @@ -199,7 +199,8 @@ private void loadWikiContent(@Nullable Container c, User user, String name, Stri public Set getIntegrationTests() { return Set.of( - WikiManager.TestCase.class + WikiManager.TestCase.class, + WikiController.CopyWikiContainerScopingTestCase.class ); } From 3c591cd9286837abc31e4eedb33cd2bb6b8f80ef Mon Sep 17 00:00:00 2001 From: Josh Eckels Date: Mon, 15 Jun 2026 09:50:01 -0700 Subject: [PATCH 05/17] 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 06/17] 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 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 07/17] 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 08/17] 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(""); - -// oldWriter.write(""); -// } -// 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("
"); - 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> getChildren(NodeForm form, BindException errors) if (c == null) c = ContainerManager.getRoot(); + if (!c.hasPermission(getUser(), ReadPermission.class)) + throw new UnauthorizedException("You do not have permission to read this summary."); + ActionURL browse = new ActionURL(BeginAction.class, c); Set> children = FileContentServiceImpl.getInstance().getNodes(form.isShowOverridesOnly(), browse.getEncodedLocalURIString(), c); @@ -756,6 +774,9 @@ protected Set> getChildren(NodeForm form, BindException erro if (c == null) c = ContainerManager.getRoot(); + if (!c.hasPermission(getUser(), ReadPermission.class)) + throw new UnauthorizedException("You do not have permission to read this project summary."); + Set> children = new LinkedHashSet<>(); FileContentService svc = FileContentService.get(); @@ -1605,5 +1626,35 @@ public void testActionPermissions() new SendShortDigestAction() ); } + + @Test + public void testSummaryActions() throws Exception + { + Container folder = JunitUtil.getTestContainer(); + User adminUser = TestContext.get().getUser(); + + // Happy path -- admin should be able to invoke summary actions in root + testAction(FileContentSummaryAction.class, folder, adminUser, HttpServletResponse.SC_OK); + testAction(FileContentProjectSummaryAction.class, folder, adminUser, HttpServletResponse.SC_OK); + + NewUserStatus newUserStatus = SecurityManager.addUser(new ValidEmail("testSummaryActions@myDomain.com"), null); + User nonAdminUser = newUserStatus.getUser(); + MutableSecurityPolicy policy = new MutableSecurityPolicy(folder.getPolicy()); + policy.addRoleAssignment(nonAdminUser, ReaderRole.class); + SecurityPolicyManager.savePolicyForTests(policy, adminUser); + + // Non-admin user should be forbidden + testAction(FileContentSummaryAction.class, folder, nonAdminUser, HttpServletResponse.SC_FORBIDDEN); + testAction(FileContentProjectSummaryAction.class, folder, nonAdminUser, HttpServletResponse.SC_FORBIDDEN); + + UserManager.deleteUser(nonAdminUser.getUserId()); + } + + private void testAction(Class actionClass, Container folder, User user, int expectedResponseCode) throws Exception + { + HttpServletRequest request = ViewServlet.mockRequest(RequestMethod.POST.name(), new ActionURL(actionClass, folder), user, Map.of("Content-Type", "application/json"), null); + MockHttpServletResponse response = ViewServlet.mockDispatch(request, null); + assertEquals("Unexpected response code", expectedResponseCode, response.getStatus()); + } } } diff --git a/mothership/src/org/labkey/mothership/MothershipController.java b/mothership/src/org/labkey/mothership/MothershipController.java index 398dec07b60..d5b8579530c 100644 --- a/mothership/src/org/labkey/mothership/MothershipController.java +++ b/mothership/src/org/labkey/mothership/MothershipController.java @@ -363,12 +363,21 @@ public static class ShowServerSessionDetailAction extends SimpleViewAction { - public ServerInstallationForm(ServerInstallation installation) - { - this(); - setBean(installation); - } - public ServerInstallationForm() { super(ServerInstallation.class, MothershipManager.get().getTableInfoServerInstallation()); } } - public static class ServerSessionForm extends BeanViewForm + public static class ServerSessionForm { - public ServerSessionForm(ServerSession session) + private Integer _serverSessionId = null; + + public Integer getServerSessionId() { - this(); - setBean(session); + return _serverSessionId; } - public ServerSessionForm() + public void setServerSessionId(Integer serverSessionId) { - super(ServerSession.class, MothershipManager.get().getTableInfoServerSession()); + _serverSessionId = serverSessionId; } } diff --git a/mothership/src/org/labkey/mothership/MothershipManager.java b/mothership/src/org/labkey/mothership/MothershipManager.java index f5a9c22f104..26eede9da92 100644 --- a/mothership/src/org/labkey/mothership/MothershipManager.java +++ b/mothership/src/org/labkey/mothership/MothershipManager.java @@ -226,6 +226,13 @@ public ServerSession getServerSession(String serverSessionGUID, Container c) return new TableSelector(getTableInfoServerSession(), filter, null).getObject(ServerSession.class); } + public ServerSession getServerSession(int serverSessionId, Container c) + { + SimpleFilter filter = SimpleFilter.createContainerFilter(c); + filter.addCondition(FieldKey.fromString("ServerSessionId"), serverSessionId); + return new TableSelector(getTableInfoServerSession(), filter, null).getObject(ServerSession.class); + } + public ExceptionStackTrace getExceptionStackTrace(String stackTraceHash, String containerId) { SimpleFilter filter = new SimpleFilter(FieldKey.fromParts("Container"), containerId); diff --git a/mothership/src/org/labkey/mothership/query/MothershipSchema.java b/mothership/src/org/labkey/mothership/query/MothershipSchema.java index 39aead99dbf..1845a0e7731 100644 --- a/mothership/src/org/labkey/mothership/query/MothershipSchema.java +++ b/mothership/src/org/labkey/mothership/query/MothershipSchema.java @@ -27,6 +27,7 @@ import org.labkey.api.data.DataColumn; import org.labkey.api.data.ForeignKey; import org.labkey.api.data.JdbcType; +import org.labkey.api.data.RenderContext; import org.labkey.api.data.SQLFragment; import org.labkey.api.data.TableInfo; import org.labkey.api.data.dialect.SqlDialect; @@ -405,18 +406,25 @@ public FilteredTable createExceptionReportTable(ContainerFilte FilteredTable result = new FilteredTable<>(MothershipManager.get().getTableInfoExceptionReport(), this, cf); result.setDetailsURL(AbstractTableInfo.LINK_DISABLER); result.wrapAllColumns(true); + // Reports are submitted by anonymous users, so untrusted. Don't render these two URLs as links. result.getMutableColumnOrThrow("URL").setDisplayColumnFactory(colInfo -> - { - DataColumn result1 = new DataColumn(colInfo); - result1.setURLExpression(StringExpressionFactory.create("${URL}", false)); - return result1; - }); + new DataColumn(colInfo) + { + @Override + protected String renderURLorValueURL(RenderContext ctx) + { + return null; + } + }); result.getMutableColumnOrThrow("ReferrerURL").setDisplayColumnFactory(colInfo -> - { - DataColumn result12 = new DataColumn(colInfo); - result12.setURLExpression(StringExpressionFactory.create("${ReferrerURL}", false)); - return result12; - }); + new DataColumn(colInfo) + { + @Override + protected String renderURLorValueURL(RenderContext ctx) + { + return null; + } + }); // Container column is on another table so join to it to filter appropriately SQLFragment containerSQL = new SQLFragment("ExceptionStackTraceId IN (SELECT ExceptionStackTraceId FROM "); diff --git a/specimen/src/org/labkey/specimen/actions/SpecimenController.java b/specimen/src/org/labkey/specimen/actions/SpecimenController.java index f3d79169d7e..9d48a30e7c7 100644 --- a/specimen/src/org/labkey/specimen/actions/SpecimenController.java +++ b/specimen/src/org/labkey/specimen/actions/SpecimenController.java @@ -5546,6 +5546,8 @@ public void addNavTrail(NavTree root) } } + public record PtidVisit(String ptid, String visit){} + @RequiresPermission(ReadPermission.class) public class SelectedSpecimensAction extends QueryViewAction { @@ -5561,26 +5563,26 @@ public SelectedSpecimensAction() protected ModelAndView getHtmlView(SpecimenViewTypeForm form, BindException errors) throws Exception { Study study = getStudyRedirectIfNull(); - Set> ptidVisits = new HashSet<>(); + Set ptidVisits = new HashSet<>(); for (ParticipantDataset pd : getFilterPds()) { if (pd.getSequenceNum() == null) { - ptidVisits.add(new Pair<>(pd.getParticipantId(), null)); + ptidVisits.add(new PtidVisit(pd.getParticipantId(), null)); } else if (study.getTimepointType() != TimepointType.VISIT && pd.getVisitDate() != null) { - ptidVisits.add(new Pair<>(pd.getParticipantId(), DateUtil.formatDate(pd.getContainer(), pd.getVisitDate()))); + ptidVisits.add(new PtidVisit(pd.getParticipantId(), DateUtil.formatDate(pd.getContainer(), pd.getVisitDate()))); } else { Visit visit = pd.getSequenceNum() != null ? StudyInternalService.get().getVisitForSequence(study, pd.getSequenceNum()) : null; - ptidVisits.add(new Pair<>(pd.getParticipantId(), visit != null ? visit.getLabel() : "" + StudyInternalService.get().formatSequenceNum(pd.getSequenceNum()))); + ptidVisits.add(new PtidVisit(pd.getParticipantId(), visit != null ? visit.getLabel() : StudyInternalService.get().formatSequenceNum(pd.getSequenceNum()))); } } SpecimenQueryView view = createInitializedQueryView(form, errors, form.getExportType() != null, null); JspView header = new JspView<>("/org/labkey/specimen/view/specimenHeader.jsp", - new SpecimenHeaderBean(getViewContext(), view, ptidVisits)); + new SpecimenHeaderBean(getViewContext(), view, ptidVisits)); return new VBox(header, view); } diff --git a/specimen/src/org/labkey/specimen/actions/SpecimenHeaderBean.java b/specimen/src/org/labkey/specimen/actions/SpecimenHeaderBean.java index fe32d12dc24..fd0cbd575df 100644 --- a/specimen/src/org/labkey/specimen/actions/SpecimenHeaderBean.java +++ b/specimen/src/org/labkey/specimen/actions/SpecimenHeaderBean.java @@ -5,13 +5,13 @@ import org.labkey.api.query.FieldKey; import org.labkey.api.query.QueryService; import org.labkey.api.specimen.SpecimenQuerySchema; -import org.labkey.specimen.query.SpecimenQueryView; import org.labkey.api.study.Study; import org.labkey.api.study.StudyService; -import org.labkey.api.util.Pair; import org.labkey.api.view.ActionURL; import org.labkey.api.view.NotFoundException; import org.labkey.api.view.ViewContext; +import org.labkey.specimen.actions.SpecimenController.PtidVisit; +import org.labkey.specimen.query.SpecimenQueryView; import java.util.Collections; import java.util.Iterator; @@ -24,7 +24,7 @@ public final class SpecimenHeaderBean private final ActionURL _otherViewURL; private final ViewContext _viewContext; private final boolean _showingVials; - private final Set> _filteredPtidVisits; + private final Set _ptidVisits; private Integer _selectedRequest; @@ -33,7 +33,7 @@ public SpecimenHeaderBean(ViewContext context, SpecimenQueryView view) this(context, view, Collections.emptySet()); } - public SpecimenHeaderBean(ViewContext context, SpecimenQueryView view, Set> filteredPtidVisits) throws RuntimeException + public SpecimenHeaderBean(ViewContext context, SpecimenQueryView view, Set ptidVisits) throws RuntimeException { Map params = context.getRequest().getParameterMap(); @@ -90,11 +90,11 @@ public SpecimenHeaderBean(ViewContext context, SpecimenQueryView view, Set> getFilteredPtidVisits() + public Set getPtidVisits() { - return _filteredPtidVisits; + return _ptidVisits; } public boolean isSingleVisitFilter() { - if (getFilteredPtidVisits().isEmpty()) + if (getPtidVisits().isEmpty()) return false; - Iterator> visitIt = getFilteredPtidVisits().iterator(); - String firstVisit = visitIt.next().getValue(); - while (visitIt.hasNext()) + Iterator ptidVisit = getPtidVisits().iterator(); + String firstVisit = ptidVisit.next().visit(); + while (ptidVisit.hasNext()) { - if (!Objects.equals(firstVisit, visitIt.next().getValue())) + if (!Objects.equals(firstVisit, ptidVisit.next().visit())) return false; } return true; diff --git a/specimen/src/org/labkey/specimen/view/manageRequirement.jsp b/specimen/src/org/labkey/specimen/view/manageRequirement.jsp index 68109262af7..6721bbc61de 100644 --- a/specimen/src/org/labkey/specimen/view/manageRequirement.jsp +++ b/specimen/src/org/labkey/specimen/view/manageRequirement.jsp @@ -84,7 +84,7 @@ Description - <%= unsafe(requirement.getDescription()) %> + <%= h(requirement.getDescription()) %> <% if (!bean.isRequestManager()) diff --git a/specimen/src/org/labkey/specimen/view/specimenHeader.jsp b/specimen/src/org/labkey/specimen/view/specimenHeader.jsp index b0599197eab..b1e2c7b5089 100644 --- a/specimen/src/org/labkey/specimen/view/specimenHeader.jsp +++ b/specimen/src/org/labkey/specimen/view/specimenHeader.jsp @@ -15,16 +15,18 @@ * limitations under the License. */ %> -<%@ page import="org.labkey.api.security.permissions.AdminPermission"%> -<%@ page import="org.labkey.api.study.StudyService"%> -<%@ page import="org.labkey.api.study.StudyUrls"%> -<%@ page import="org.labkey.api.util.Pair"%> +<%@ page import="org.apache.commons.lang3.StringUtils" %> +<%@ page import="org.labkey.api.security.permissions.AdminPermission" %> +<%@ page import="org.labkey.api.study.StudyService" %> +<%@ page import="org.labkey.api.study.StudyUrls" %> +<%@ page import="org.labkey.api.util.HtmlStringBuilder" %> <%@ page import="org.labkey.api.view.ActionURL" %> <%@ page import="org.labkey.api.view.HttpView" %> <%@ page import="org.labkey.api.view.JspView" %> <%@ page import="org.labkey.api.view.template.ClientDependencies" %> <%@ page import="org.labkey.specimen.actions.ShowSearchAction" %> <%@ page import="org.labkey.specimen.actions.SpecimenController" %> +<%@ page import="org.labkey.specimen.actions.SpecimenController.PtidVisit" %> <%@ page import="org.labkey.specimen.actions.SpecimenController.SpecimensAction" %> <%@ page import="org.labkey.specimen.actions.SpecimenHeaderBean" %> <%@ page import="java.util.Iterator" %> @@ -74,47 +76,57 @@ <%=link("Search", ShowSearchAction.getShowSearchURL(getContainer(), bean.isShowingVials()))%>  <%=link("Reports", urlFor(SpecimenController.AutoReportListAction.class)) %> <% - if (!bean.getFilteredPtidVisits().isEmpty()) + if (!bean.getPtidVisits().isEmpty()) { // get the first visit label: - StringBuilder filterString = new StringBuilder(); - filterString.append("This view is displaying specimens only from "); - boolean usePlural = bean.getFilteredPtidVisits().size() != 1; + HtmlStringBuilder builder = HtmlStringBuilder.of() + .unsafeAppend("") + .append("This view is displaying specimens only from "); + boolean usePlural = bean.getPtidVisits().size() != 1; if (bean.isSingleVisitFilter()) { - filterString.append(h((usePlural?subjectNounPlural:subjectNounSingle).toLowerCase())).append(" "); - for (Iterator> it = bean.getFilteredPtidVisits().iterator(); it.hasNext();) + builder.append((usePlural ? subjectNounPlural : subjectNounSingle).toLowerCase()) + .append(" "); + for (Iterator it = bean.getPtidVisits().iterator(); it.hasNext();) { - String ptid = it.next().getKey(); - filterString.append(ptid); + String ptid = it.next().ptid(); + builder.append(ptid); if (it.hasNext()) - filterString.append(", "); + builder.append(", "); } - String visit = bean.getFilteredPtidVisits().iterator().next().getValue(); + String visit = bean.getPtidVisits().iterator().next().visit(); if (visit != null) - filterString.append(" at visit ").append(visit); - filterString.append(".
"); + builder.append(" at visit ").append(visit); + + builder.append(".") + .unsafeAppend("

"); } else { - filterString.append(" the following ").append(h(subjectNounSingle.toLowerCase())).append("/visit ").append(usePlural?"pairs":"pair").append(":
"); - for (Iterator> it = bean.getFilteredPtidVisits().iterator(); it.hasNext();) + builder.append(" the following ") + .append(subjectNounSingle.toLowerCase()) + .append("/visit ").append(usePlural ? "pairs" : "pair") + .append(":") + .unsafeAppend("
"); + for (Iterator it = bean.getPtidVisits().iterator(); it.hasNext();) { - Pair ptidVisit = it.next(); - filterString.append(ptidVisit.getKey()).append("/").append(ptidVisit.getValue()); + PtidVisit ptidVisit = it.next(); + builder.append(ptidVisit.ptid()) + .append("/") + .append(StringUtils.trimToEmpty(ptidVisit.visit())); if (it.hasNext()) - filterString.append(", "); + builder.append(", "); } - filterString.append("."); + builder.append("."); } - ActionURL noFitlerUrl = getViewContext().cloneActionURL().setAction(SpecimensAction.class); + ActionURL noFilterUrl = getViewContext().cloneActionURL().setAction(SpecimensAction.class); %>

- +
<%= unsafe(filterString.toString()) %>
<%=builder%>

-<%= 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 { @Override @@ -661,13 +663,22 @@ public int getReportId() return reportId; } + @SuppressWarnings("unused") public void setReportId(int reportId) { this.reportId = reportId; } - public void setReportView(String label){_reportView = label;} - public String getReportView(){return _reportView;} + public String getReportView() + { + return _reportView; + } + + @SuppressWarnings("unused") + public void setReportView(String label) + { + _reportView = label; + } } public static class SaveReportForm extends ViewForm @@ -765,13 +776,35 @@ public void setShowWithDataset(int showWithDataset) this.showWithDataset = showWithDataset; } - public void setRedirectToDataset(Integer dataset){redirectToDataset = dataset;} - public Integer getRedirectToDataset(){return redirectToDataset;} + public Integer getRedirectToDataset() + { + return redirectToDataset; + } + + public void setRedirectToDataset(Integer dataset) + { + redirectToDataset = dataset; + } + + public String getDescription() + { + return this.description; + } + + public void setDescription(String description) + { + this.description = description; + } + + public BindException getErrors() + { + return _errors; + } - public void setDescription(String description){this.description = description;} - public String getDescription(){return this.description;} - public void setErrors(BindException errors){_errors = errors;} - public BindException getErrors(){return _errors;} + public void setErrors(BindException errors) + { + _errors = errors; + } public String getDataRegionName() { @@ -829,19 +862,57 @@ public Report getReport(ContainerUser cu) return null; } - public void setShareReport(boolean shareReport){_shareReport = shareReport;} - public boolean getShareReport(){return _shareReport;} + public void setShareReport(boolean shareReport) + { + _shareReport = shareReport; + } + + public boolean getShareReport() + { + return _shareReport; + } + + public void setSchemaName(String schemaName) + { + _schemaName = schemaName; + } + + public String getSchemaName() + { + return _schemaName; + } + + public void setQueryName(String queryName) + { + _queryName = queryName; + } + + public String getQueryName() + { + return _queryName; + } + + public void setViewName(String viewName) + { + _viewName = viewName; + } + + public String getViewName() + { + return _viewName; + } - public void setSchemaName(String schemaName){_schemaName = schemaName;} - public String getSchemaName(){return _schemaName;} - public void setQueryName(String queryName){_queryName = queryName;} - public String getQueryName(){return _queryName;} - public void setViewName(String viewName){_viewName = viewName;} - public String getViewName(){return _viewName;} @Override - public void setDataRegionName(String dataRegionName){_dataRegionName = dataRegionName;} + public void setDataRegionName(String dataRegionName) + { + _dataRegionName = dataRegionName; + } + @Override - public String getDataRegionName(){return _dataRegionName;} + public String getDataRegionName() + { + return _dataRegionName; + } public String getRedirectUrl() { @@ -969,7 +1040,9 @@ private void _addNavTrail(NavTree root, String name) if (getContainer().hasPermission(getUser(), AdminPermission.class)) root.addChild("Manage Views", urlProvider(ReportUrls.class).urlManageViews(getContainer())); } - catch (Exception ignored) {} + catch (Exception ignored) + { + } root.addChild(name); } @@ -1217,4 +1290,36 @@ public void bindJson(JSONObject json) } } } + + public static class TestCase extends AbstractActionPermissionTest + { + @Override + @Test + public void testActionPermissions() + { + User user = TestContext.get().getUser(); + assertTrue(user.hasSiteAdminPermission()); + + ReportsController controller = new ReportsController(); + + // @RequiresPermission(ReadPermission.class) + assertForReadPermission(user, false, + controller.new BeginAction(), + new StreamFileAction(), + controller.new SaveReportAction(), + controller.new SaveReportViewAction(), + new ShowReportAction(), + new ParticipantCrosstabAction(), + controller.new CreateCrosstabReportAction(), + controller.new RunRReportAction(), + controller.new ParticipantReportAction(), + new SaveParticipantReportAction() + ); + + // @RequiresPermission(AdminPermission.class) + assertForAdminPermission(user, + controller.new CreateQueryReportAction() + ); + } + } } diff --git a/study/src/org/labkey/study/dataset/DataStatesTest.java b/study/src/org/labkey/study/dataset/DataStatesTest.java new file mode 100644 index 00000000000..7d231cf4bf0 --- /dev/null +++ b/study/src/org/labkey/study/dataset/DataStatesTest.java @@ -0,0 +1,56 @@ +package org.labkey.study.dataset; + +import jakarta.servlet.http.HttpServletResponse; +import org.junit.Test; +import org.labkey.api.data.Container; +import org.labkey.api.qc.DataState; +import org.labkey.api.qc.DataStateManager; +import org.labkey.api.security.permissions.AbstractContainerScopingTest; +import org.labkey.api.view.ActionURL; +import org.labkey.study.controllers.StudyController; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.util.List; + +public class DataStatesTest extends AbstractContainerScopingTest +{ + @Test + public void testManageDataStates() throws Exception + { + // Note: This test will log OptimisticConflictException warnings + // We're exercising DataStateManger via study ManageQCStatesAction, but we don't need to create actual studies + Container folderA = createContainer("Folder A"); + Container folderB = createContainer("Folder B"); + + // Create a basic data state in Folder A + DataState state1 = new DataState(); + state1.setContainer(folderA); + state1.setLabel("State 1"); + state1.setPublicData(true); + state1 = DataStateManager.getInstance().insertState(getAdmin(), state1); + List statesInA = DataStateManager.getInstance().getStates(folderA); + assertEquals(1, statesInA.size()); + assertTrue(statesInA.contains(state1)); + + // Attempt to update that data state from the wrong folder, Folder B. Admin should *not* be able to update it. + ActionURL url = new ActionURL(StudyController.ManageQCStatesAction.class, folderB) + .addParameter("ids", state1.getRowId()) + .addParameter("labels", "Here's my new label"); + HttpServletResponse response = post(url, getAdmin()); + List statesInB = DataStateManager.getInstance().getStates(folderB); + assertTrue(statesInB.isEmpty()); + statesInA = DataStateManager.getInstance().getStates(folderA); + assertEquals(1, statesInA.size()); + assertTrue(statesInA.contains(state1)); + assertStatus(MockHttpServletResponse.SC_INTERNAL_SERVER_ERROR, response); // Error response + + // Admin should be able to update the data state in Folder A + url.setContainer(folderA); + post(url, getAdmin()); + statesInA = DataStateManager.getInstance().getStates(folderA); + assertEquals(1, statesInA.size()); + DataState updatedState = statesInA.get(0); + assertEquals("Here's my new label", updatedState.getLabel()); + assertFalse(updatedState.isPublicData()); + } +} From 1d6b99a614f2ed55413f0e9f13b5fd188dd2a159 Mon Sep 17 00:00:00 2001 From: Susan Hert Date: Tue, 16 Jun 2026 15:30:29 -0700 Subject: [PATCH 09/17] Kanban Issue 1924: Additional container scoping updates (#7753) --- api/src/org/labkey/api/exp/OntologyManager.java | 5 +++-- .../publish/AbstractPublishConfirmAction.java | 5 +++++ assay/src/org/labkey/assay/AssayController.java | 8 ++++++++ core/src/org/labkey/core/CoreController.java | 8 ++++++++ core/src/org/labkey/core/user/UserController.java | 4 ++++ .../controllers/exp/ExperimentController.java | 14 +++++++++++++- .../controllers/property/PropertyController.java | 2 +- 7 files changed, 42 insertions(+), 4 deletions(-) diff --git a/api/src/org/labkey/api/exp/OntologyManager.java b/api/src/org/labkey/api/exp/OntologyManager.java index c5fd1af0111..5268b45d518 100644 --- a/api/src/org/labkey/api/exp/OntologyManager.java +++ b/api/src/org/labkey/api/exp/OntologyManager.java @@ -2841,13 +2841,14 @@ public static Object getRemappedValueForLookup(User user, Container container, R return cache.remap(SchemaKey.fromParts(lookup.getSchemaKey()), lookup.getQueryName(), user, lkContainer, ContainerFilter.Type.CurrentPlusProjectAndShared, String.valueOf(value)); } - public static List findPropertyUsages(User user, List propertyIds, int maxUsageCount) + public static List findPropertyUsagesByIds(User user, Container container, List propertyIds, int maxUsageCount) { List ret = new ArrayList<>(propertyIds.size()); for (int propertyId : propertyIds) { var pd = getPropertyDescriptor(propertyId); - if (pd == null) + // Kanban #1924: Get property descriptors for the current container only + if (pd == null || !pd.getContainer().equals(container)) throw new IllegalArgumentException("property not found: " + propertyId); ret.add(findPropertyUsages(user, pd, maxUsageCount)); diff --git a/api/src/org/labkey/api/study/publish/AbstractPublishConfirmAction.java b/api/src/org/labkey/api/study/publish/AbstractPublishConfirmAction.java index e004ad49333..aee4cb7f297 100644 --- a/api/src/org/labkey/api/study/publish/AbstractPublishConfirmAction.java +++ b/api/src/org/labkey/api/study/publish/AbstractPublishConfirmAction.java @@ -42,6 +42,7 @@ import org.labkey.api.view.ActionURL; import org.labkey.api.view.JspView; import org.labkey.api.view.RedirectException; +import org.labkey.api.view.UnauthorizedException; import org.labkey.api.view.VBox; import org.labkey.api.view.template.ClientDependency; import org.springframework.validation.BindException; @@ -107,6 +108,10 @@ public void validateCommand(FORM form, Errors errors) { errors.reject(SpringActionController.ERROR_MSG, "Could not find target study"); } + else if (!_targetStudy.hasPermission(getUser(), InsertPermission.class)) + { + throw new UnauthorizedException("You do not have permission to insert into the target study"); + } } if (_targetStudy != null) diff --git a/assay/src/org/labkey/assay/AssayController.java b/assay/src/org/labkey/assay/AssayController.java index 507630bae50..3a8233bfc34 100644 --- a/assay/src/org/labkey/assay/AssayController.java +++ b/assay/src/org/labkey/assay/AssayController.java @@ -1457,6 +1457,9 @@ public Object execute(Object form, BindException errors) throws Exception ExpRun expRun = ExperimentService.get().getExpRun(NumberUtils.toInt(run)); if (expRun != null) { + // Kanban #1924 assure permissions to the run's container, which might be different from the current container + if (!expRun.getContainer().hasPermission(getUser(), AssayReadPermission.class)) + throw new UnauthorizedException("User does not have " + AssayReadPermission.class.getSimpleName() + " for run " + run); response.put("success", true); DataState state = AssayQCService.getProvider().getQCState(expRun.getProtocol(), expRun.getRowId()); if (state != null) @@ -1762,6 +1765,11 @@ public Object execute(AssayOperationConfirmationForm form, BindException errors) ExperimentService service = ExperimentService.get(); ExpProtocol protocol = service.getExpProtocol(form.getProtocolId()); + if (protocol == null) + throw new NotFoundException("Protocol with id " + form.getProtocolId() + " not found."); + // Kanban #1924: Assure permission in the protocol's container, which may be different than the current container + if (!protocol.getContainer().hasPermission(getUser(), ReadPermission.class)) + throw new UnauthorizedException("User does not have permission to read protocol " + protocol.getName()); AssayProvider provider = AssayService.get().getProvider(protocol); if (provider == null) throw new NotFoundException("No provider found for protocol " + form.getProtocolId()); diff --git a/core/src/org/labkey/core/CoreController.java b/core/src/org/labkey/core/CoreController.java index 10d71253623..139091a8e22 100644 --- a/core/src/org/labkey/core/CoreController.java +++ b/core/src/org/labkey/core/CoreController.java @@ -413,6 +413,9 @@ else if (form.getObjectURI() != null) if (!obj.getContainer().equals(getContainer())) { + // Kanban #1924: Assure permission in the object's container + if (!obj.getContainer().hasPermission(getUser(), ReadPermission.class)) + throw new UnauthorizedException(); ActionURL correctedURL = getViewContext().getActionURL().clone(); Container objectContainer = obj.getContainer(); if (objectContainer == null) @@ -1775,6 +1778,11 @@ public ApiResponse execute(ContainerInfoForm form, BindException errors) { // Provide information about container, specifically an array of child tab folders that were deleted Container container = form.getContainerPath() != null ? ContainerManager.getForPath(form.getContainerPath()) : getContainer(); + if (container == null) + throw new NotFoundException("No container found for path: " + form.getContainerPath()); + // Kanban #1924: Assure permission to the container + if (!container.hasPermission(getUser(), ReadPermission.class)) + throw new UnauthorizedException("You do not have permission to view the container information."); JSONArray deletedFolders = new JSONArray(); for (FolderTab folderTab : container.getDeletedTabFolders(form.getNewFolderType())) { diff --git a/core/src/org/labkey/core/user/UserController.java b/core/src/org/labkey/core/user/UserController.java index 49d9ef0d694..00141a80732 100644 --- a/core/src/org/labkey/core/user/UserController.java +++ b/core/src/org/labkey/core/user/UserController.java @@ -2636,6 +2636,10 @@ protected Collection getProjectGroupUsers(GetUsersForm form, ApiSimpleResp if (null == group) throw new NotFoundException("Cannot find group with id " + groupId); + // Kanban #1924: Assure permission in the group's container + Container groupContainer = ContainerManager.getForId(group.getContainer()); + if (null != groupContainer && !groupContainer.hasPermission(getUser(), ReadPermission.class)) + throw new UnauthorizedException("You do not have permission to see information about the group '" + group.getName() + "'"); response.put("groupId", group.getUserId()); response.put("groupName", group.getName()); response.put("groupCaption", SecurityManager.getDisambiguatedGroupName(group)); diff --git a/experiment/src/org/labkey/experiment/controllers/exp/ExperimentController.java b/experiment/src/org/labkey/experiment/controllers/exp/ExperimentController.java index 33bfd5a917d..3e12c530585 100644 --- a/experiment/src/org/labkey/experiment/controllers/exp/ExperimentController.java +++ b/experiment/src/org/labkey/experiment/controllers/exp/ExperimentController.java @@ -737,7 +737,8 @@ public ApiResponse execute(SimpleApiJsonForm form, BindException errors) throws JSONArray runIds = json.getJSONArray("runIds"); for (int i = 0; i < runIds.length(); i++) { - ExpRunImpl run = ExperimentServiceImpl.get().getExpRun(runIds.getInt(i)); + // Kanban #1924: Make sure the run belongs to the current container. + ExpRunImpl run = ExperimentServiceImpl.get().getExpRun(runIds.getInt(i), getContainer()); if (run != null) { runs.add(run); @@ -7962,7 +7963,13 @@ public Object execute(EntitySequenceForm form, BindException errors) throws Exce { ExpSampleType sampleType = SampleTypeService.get().getSampleType(form.getRowId()); if (sampleType != null) + { + // Kanban #1924: Assure permission in the sample type's container + if (!sampleType.getContainer().hasPermission(getUser(), ReadPermission.class)) + throw new UnauthorizedException("You do not have permission to read this sample type."); value = sampleType.getCurrentGenId(); + } + } else { @@ -7974,7 +7981,12 @@ else if (DataClassDomainKind.NAME.equalsIgnoreCase(form.getKindName())) { ExpDataClass dataClass = ExperimentService.get().getDataClass(form.getRowId()); if (dataClass != null) + { + // Kanban #1924: assure permission in the data class's container + if (!dataClass.getContainer().hasPermission(getUser(), ReadPermission.class)) + throw new UnauthorizedException("You do not have permission to read this data class."); value = dataClass.getCurrentGenId(); + } } ApiSimpleResponse resp = new ApiSimpleResponse(); diff --git a/experiment/src/org/labkey/experiment/controllers/property/PropertyController.java b/experiment/src/org/labkey/experiment/controllers/property/PropertyController.java index c1ee2680a67..30975c0b247 100644 --- a/experiment/src/org/labkey/experiment/controllers/property/PropertyController.java +++ b/experiment/src/org/labkey/experiment/controllers/property/PropertyController.java @@ -2067,7 +2067,7 @@ public Object execute(PropertyUsagesForm form, BindException errors) throws Exce List usages = null; if (form.getPropertyIds() != null) { - usages = OntologyManager.findPropertyUsages(getUser(), form.getPropertyIds(), form.maxUsageCount); + usages = OntologyManager.findPropertyUsagesByIds(getUser(), getContainer(), form.getPropertyIds(), form.maxUsageCount); } else if (form.getPropertyURIs() != null) { From b4d38c458f339d9f9500490019c7a10824c67ab0 Mon Sep 17 00:00:00 2001 From: Josh Eckels Date: Tue, 16 Jun 2026 16:44:55 -0700 Subject: [PATCH 10/17] Improve scoping checks for objects, round 2 (#7749) #### Rationale We can improve our parameter validation #### Changes - New test coverage - Assorted scoping checks --- .../announcements/AnnouncementModule.java | 3 +- .../labkey/announcements/ToursController.java | 41 ++ .../api/data/AbstractParticipantCategory.java | 5 +- .../org/labkey/api/data/TableViewForm.java | 67 +++- .../AbstractContainerScopingTest.java | 20 +- .../src/org/labkey/assay/AssayController.java | 170 ++++++++ assay/src/org/labkey/assay/AssayModule.java | 1 + .../org/labkey/assay/plate/PlateManager.java | 11 + core/src/org/labkey/core/CoreModule.java | 1 + .../labkey/core/admin/AdminController.java | 70 ++++ .../miniprofiler/MiniProfilerController.java | 10 +- .../controllers/exp/ExperimentController.java | 373 +++++++++++++++++- .../mothership/MothershipController.java | 53 ++- .../labkey/pipeline/PipelineController.java | 68 +++- .../org/labkey/pipeline/PipelineModule.java | 1 + .../pipeline/api/PipelineStatusManager.java | 5 +- .../query/TriggerConfigurationsTable.java | 5 +- .../pipeline/status/StatusController.java | 149 ++++++- query/src/org/labkey/query/QueryModule.java | 1 + .../query/controllers/OlapController.java | 67 ++++ .../actions/SpecimenApiController.java | 1 + .../specimen/actions/SpecimenController.java | 78 ++++ study/src/org/labkey/study/StudyModule.java | 6 +- .../study/controllers/StudyController.java | 55 ++- .../reports/ReportsController.java | 41 ++ .../study/model/ParticipantGroupManager.java | 64 ++- .../studydesign/StudyDesignController.java | 52 +++ .../labkey/studydesign/StudyDesignModule.java | 3 +- .../studydesign/model/TreatmentManager.java | 18 +- .../src/org/labkey/survey/SurveyManager.java | 31 +- wiki/src/org/labkey/wiki/WikiController.java | 73 +++- 31 files changed, 1441 insertions(+), 102 deletions(-) diff --git a/announcements/src/org/labkey/announcements/AnnouncementModule.java b/announcements/src/org/labkey/announcements/AnnouncementModule.java index 0d1e6466ad9..f4888b00295 100644 --- a/announcements/src/org/labkey/announcements/AnnouncementModule.java +++ b/announcements/src/org/labkey/announcements/AnnouncementModule.java @@ -216,7 +216,8 @@ public void startBackgroundThreads() public Set getIntegrationTests() { return Set.of( - AnnouncementManager.TestCase.class + AnnouncementManager.TestCase.class, + ToursController.ContainerScopingTestCase.class ); } diff --git a/announcements/src/org/labkey/announcements/ToursController.java b/announcements/src/org/labkey/announcements/ToursController.java index 7fa4054f84c..49c6bd7bbb6 100644 --- a/announcements/src/org/labkey/announcements/ToursController.java +++ b/announcements/src/org/labkey/announcements/ToursController.java @@ -15,7 +15,9 @@ */ package org.labkey.announcements; +import jakarta.servlet.http.HttpServletResponse; import org.json.JSONObject; +import org.junit.Test; import org.labkey.announcements.model.TourManager; import org.labkey.announcements.model.TourModel; import org.labkey.announcements.query.AnnouncementSchema; @@ -30,10 +32,17 @@ import org.labkey.api.query.QueryView; import org.labkey.api.security.ActionNames; import org.labkey.api.security.RequiresPermission; +import org.labkey.api.security.User; +import org.labkey.api.security.permissions.AbstractContainerScopingTest; +import org.labkey.api.security.permissions.AdminPermission; import org.labkey.api.security.permissions.ReadPermission; +import org.labkey.api.security.roles.FolderAdminRole; +import org.labkey.api.security.roles.ReaderRole; import org.labkey.api.view.ActionURL; import org.labkey.api.view.JspView; import org.labkey.api.view.NavTree; +import org.labkey.api.view.UnauthorizedException; +import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.validation.BindException; import org.springframework.validation.Errors; import org.springframework.web.servlet.ModelAndView; @@ -123,6 +132,12 @@ public static class SaveTourAction extends MutatingApiAction @Override public void validateForm(SimpleApiJsonForm form, Errors errors) { + // The "//will check below" gate on the annotation was never implemented: this action inserts/updates tour + // content (a folder-level configuration asset) but performed no insert/update/admin check, so a Read user + // could create or overwrite tours. Require folder admin to manage tours. + if (!getContainer().hasPermission(getUser(), AdminPermission.class)) + throw new UnauthorizedException("You do not have permission to modify tours in this folder."); + TourModel model; JSONObject json = form.getJsonObject(); int rowId = json.getInt("rowId"); @@ -203,4 +218,30 @@ public void setRowid(String rowid) _rowid = rowid; } } + + public static class ContainerScopingTestCase extends AbstractContainerScopingTest + { + @Test + public void testSaveTourRequiresAdmin() throws Exception + { + Container folder = createContainer("A"); + ActionURL url = new ActionURL(SaveTourAction.class, folder); + + // A Reader must not be able to create/modify tours + User reader = createUserInRole(folder, ReaderRole.class); + JSONObject body = new JSONObject().put("rowId", -1); + assertStatus(HttpServletResponse.SC_FORBIDDEN, postJson(url, reader, body)); + + // Positive control: a folder admin passes the permission gate and the tour is created (success, 200). + User folderAdmin = createUserInRole(folder, FolderAdminRole.class); + JSONObject adminBody = new JSONObject() + .put("rowId", -1) + .put("title", "scoping-test-tour") + .put("description", "d") + .put("mode", "0") + .put("tour", new JSONObject()); + MockHttpServletResponse resp = postJson(url, folderAdmin, adminBody); + assertStatus(HttpServletResponse.SC_OK, resp); + } + } } diff --git a/api/src/org/labkey/api/data/AbstractParticipantCategory.java b/api/src/org/labkey/api/data/AbstractParticipantCategory.java index 914e1a939ee..00ab7ad438b 100644 --- a/api/src/org/labkey/api/data/AbstractParticipantCategory.java +++ b/api/src/org/labkey/api/data/AbstractParticipantCategory.java @@ -229,7 +229,10 @@ public boolean canEdit(Container container, User user, List err else { User owner = UserManager.getUser(getCreatedBy()); - boolean allowed = (owner != null && !owner.isGuest()) ? owner.equals(user) : false; + boolean allowed = + container.hasPermission(user, SharedParticipantGroupPermission.class) || + container.hasPermission(user, AdminPermission.class) || + (owner != null && !owner.isGuest() && owner.equals(user)); if (!allowed) errors.add(new SimpleValidationError("You must be the owner to unshare this participant category")); diff --git a/api/src/org/labkey/api/data/TableViewForm.java b/api/src/org/labkey/api/data/TableViewForm.java index 7254abb1e69..6b4a2b62506 100644 --- a/api/src/org/labkey/api/data/TableViewForm.java +++ b/api/src/org/labkey/api/data/TableViewForm.java @@ -34,6 +34,7 @@ import org.labkey.api.action.NullSafeBindException; import org.labkey.api.action.SpringActionController; import org.labkey.api.collections.CaseInsensitiveHashMap; +import org.labkey.api.query.FieldKey; import org.labkey.api.query.SchemaKey; import org.labkey.api.security.permissions.DeletePermission; import org.labkey.api.security.permissions.InsertPermission; @@ -183,8 +184,21 @@ public void doUpdate() throws SQLException throw new UnauthorizedException(); } - if (null != _tinfo.getColumn("container")) + FieldKey containerFK = FieldKey.fromParts("Container"); + if (null != _tinfo.getColumn(containerFK)) + { + // The hasPermission() check above only proves the user can update the *current* container. The UPDATE below + // keys on the PK alone and stamps the row into the current container, so without this guard a user with + // update permission here could edit (and re-home) a row that actually lives in another container simply by + // POSTing its PK. Confirm the existing row is in this container; 404 otherwise. PkFilter validates and + // converts the PK as well, so a missing or malformed key likewise 404s here rather than later. + SimpleFilter filter = new PkFilter(_tinfo, getPkVals()); + filter.addCondition(containerFK, _c.getId()); + if (!new TableSelector(_tinfo, filter, null).exists()) + throw new NotFoundException("No matching row found in this folder"); + set("container", _c.getId()); + } Object[] pkVal = getPkVals(); Map newMap = Table.update(_user, _tinfo, getTypedValues(), pkVal); @@ -207,21 +221,50 @@ public void doDelete() throw new UnauthorizedException(); } - if (null != _selectedRows && _selectedRows.length > 0) - { - for (String selectedRow : _selectedRows) - Table.delete(_tinfo, selectedRow); - } - else + // Table.delete() keys on the PK alone. As with doUpdate(), the DeletePermission check only proves the user can + // delete in the *current* container, so for container-scoped tables we must confirm each target row lives here; + // otherwise a user could delete a row that belongs to another container by POSTing (or grid-selecting) its PK. + FieldKey containerFK = FieldKey.fromParts("Container"); + boolean scopeToContainer = null != _tinfo.getColumn(containerFK); + + try (DbScope.Transaction t = _tinfo.getSchema().getScope().ensureTransaction()) { - Object[] pkVal = getPkVals(); - if (null != pkVal && null != pkVal[0]) - Table.delete(_tinfo, pkVal); - else //Hmm, throw an exception here???? - _log.warn("Nothing to delete for table " + _tinfo.getName() + " on request " + _request.getRequestURI()); + if (null != _selectedRows && _selectedRows.length > 0) + { + for (String selectedRow : _selectedRows) + { + if (scopeToContainer) + deleteInContainer(selectedRow, containerFK); + else + Table.delete(_tinfo, selectedRow); + } + } + else + { + Object[] pkVal = getPkVals(); + if (null != pkVal && null != pkVal[0]) + { + if (scopeToContainer) + deleteInContainer(pkVal, containerFK); + else + Table.delete(_tinfo, pkVal); + } + else //Hmm, throw an exception here???? + _log.warn("Nothing to delete for table " + _tinfo.getName() + " on request " + _request.getRequestURI()); + } } } + // Deletes a single row only if it lives in the form's container, scoping the DELETE's WHERE clause to the PK and the + // container together. 404s on a miss (cross-container or already gone), mirroring doUpdate(). + private void deleteInContainer(Object pkVal, FieldKey containerFK) + { + SimpleFilter filter = new PkFilter(_tinfo, pkVal); + filter.addCondition(containerFK, _c.getId()); + if (Table.delete(_tinfo, filter) == 0) + throw new NotFoundException("No matching row found in this folder"); + } + /** * Pulls in the data from the current row of the database. */ diff --git a/api/src/org/labkey/api/security/permissions/AbstractContainerScopingTest.java b/api/src/org/labkey/api/security/permissions/AbstractContainerScopingTest.java index fd0ea652544..37841fadeaa 100644 --- a/api/src/org/labkey/api/security/permissions/AbstractContainerScopingTest.java +++ b/api/src/org/labkey/api/security/permissions/AbstractContainerScopingTest.java @@ -16,6 +16,7 @@ package org.labkey.api.security.permissions; import jakarta.servlet.http.HttpServletResponse; +import org.json.JSONObject; import org.junit.After; import org.junit.Assert; import org.labkey.api.data.Container; @@ -57,6 +58,7 @@ public abstract class AbstractContainerScopingTest extends Assert { private static final Map FORM_HEADERS = Map.of("Content-Type", "application/x-www-form-urlencoded"); + private static final Map JSON_HEADERS = Map.of("Content-Type", "application/json"); private final List _containers = new ArrayList<>(); private final List _users = new ArrayList<>(); @@ -75,7 +77,17 @@ protected User getAdmin() protected Container createContainer(String name) { Container junit = JunitUtil.getTestContainer(); - Container c = ContainerManager.ensureContainer(junit.getParsedPath().append(getClass().getSimpleName() + "-" + name, true), getAdmin()); + // Use the fully-qualified class name, not getSimpleName(): the nested test class is named + // "ContainerScopingTestCase" in nearly every controller, so getSimpleName() would give them all the SAME + // /_junit child path and they would share fixtures (and collide on unique constraints across runs). Sanitize + // to a valid folder name, and force-delete any fixture an interrupted prior run left behind so each run starts + // from a clean container even when a previous @After could not complete. + String prefix = getClass().getName().replaceAll("[^A-Za-z0-9]", "_"); + var path = junit.getParsedPath().append(prefix + "-" + name, true); + Container existing = ContainerManager.getForPath(path); + if (existing != null) + ContainerManager.deleteAll(existing, getAdmin()); + Container c = ContainerManager.ensureContainer(path, getAdmin()); _containers.add(c); return c; } @@ -124,6 +136,12 @@ protected MockHttpServletResponse post(ActionURL url, User user) throws Exceptio return ViewServlet.POST(url, user, FORM_HEADERS, null); } + /** Dispatch a JSON POST to a {@code @Marshal(Jackson)} API action, with {@code body} supplied as the request body. */ + protected MockHttpServletResponse postJson(ActionURL url, User user, JSONObject body) throws Exception + { + return ViewServlet.POST(url, user, JSON_HEADERS, body.toString()); + } + /** Assert that a dispatched response has the expected HTTP status code. */ protected void assertStatus(int expected, HttpServletResponse response) { diff --git a/assay/src/org/labkey/assay/AssayController.java b/assay/src/org/labkey/assay/AssayController.java index 3a8233bfc34..6f3c5819eed 100644 --- a/assay/src/org/labkey/assay/AssayController.java +++ b/assay/src/org/labkey/assay/AssayController.java @@ -160,11 +160,31 @@ import org.springframework.validation.Errors; import org.springframework.web.servlet.ModelAndView; import org.springframework.web.servlet.mvc.Controller; +import org.springframework.mock.web.MockMultipartHttpServletRequest; +import jakarta.servlet.http.HttpSession; +import org.junit.After; +import org.junit.Test; +import org.labkey.api.assay.AssayDataCollector; +import org.labkey.api.assay.AssayRunCreator; +import org.labkey.api.assay.PipelineDataCollector; +import org.labkey.api.exp.api.ExpExperiment; +import org.labkey.api.gwt.client.assay.model.GWTProtocol; +import org.labkey.api.gwt.client.model.GWTDomain; +import org.labkey.api.gwt.client.model.GWTPropertyDescriptor; +import org.labkey.api.security.permissions.AbstractContainerScopingTest; +import org.labkey.api.security.roles.EditorRole; +import org.labkey.api.security.roles.ReaderRole; +import org.labkey.api.util.JunitUtil; +import org.labkey.api.util.TestContext; +import org.labkey.api.view.ViewBackgroundInfo; +import org.labkey.api.view.ViewContext; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -1419,6 +1439,24 @@ public ApiResponse execute(SetResultFlagForm form, BindException errors) if (!org.labkey.api.gwt.client.ui.PropertyType.expFlag.getURI().equals(flagCol.getConceptURI())) throw new NotFoundException(); + // Confirm every requested result row actually belongs to the request container by reading its run's container + Set requestedIds = new HashSet<>(form.getRowList()); + if (!requestedIds.isEmpty()) + { + SimpleFilter idFilter = new SimpleFilter(); + idFilter.addInClause(FieldKey.fromParts("RowId"), requestedIds); + Map[] rows = new TableSelector(assayResultTable, PageFlowUtil.set("RowId", "Folder"), idFilter, null).getMapArray(); + Set allowedIds = new HashSet<>(); + String currentContainerId = getContainer().getId(); + for (Map row : rows) + { + if (currentContainerId.equals(String.valueOf(row.get("Folder"))) && row.get("RowId") instanceof Number n) + allowedIds.add(n.intValue()); + } + if (!allowedIds.containsAll(requestedIds)) + throw new NotFoundException("One or more result rows were not found in this folder"); + } + DbScope scope = ti.getSchema().getScope(); int rowsAffected = 0 ; try (DbScope.Transaction transaction = scope.ensureTransaction()) @@ -1608,6 +1646,14 @@ public ApiSimpleResponse execute(UpdateQCStateForm form, BindException errors) t ApiSimpleResponse response = new ApiSimpleResponse(); if (form.getRuns() != null && _firstRun != null) { + for (int id : form.getRuns()) + { + // Support cross-container operations but confirm permission + ExpRun run = ExperimentService.get().getExpRun(id); + if (run == null || !run.getContainer().hasPermission(getUser(), QCAnalystPermission.class)) + throw new NotFoundException("Run " + id + " not found in this folder"); + } + DataState state = DataStateManager.getInstance().getStateForRowId(_firstRun.getProtocol().getContainer(), form.getState()); if (state != null) AssayQCService.getProvider().setQCStates(_firstRun.getProtocol(), getContainer(), getUser(), List.copyOf(form.getRuns()), state, form.getComment()); @@ -1965,4 +2011,128 @@ public Object execute(FilterCriteriaColumnsForm form, BindException errors) thro return AssayPlateMetadataService.get().previewFilterCriteriaColumns(protocol, form.getColumnNames()); } } + + /** + * Verifies that assay actions resolving result rows by global RowId reject rows that belong to a different + * container, even when the caller has the action's required permission in the request folder. The design is created + * in /Shared so its protocol resolves from any sibling folder (project + shared scope, per ProtocolIdForm), while + * the result rows live in a folder where the caller has no rights. + */ + public static class ContainerScopingTestCase extends AbstractContainerScopingTest + { + private ExpProtocol _sharedProtocol; // lives in /Shared; not auto-cleaned, so we delete it in @After + + @After + public void deleteSharedAssay() + { + if (_sharedProtocol != null) + { + try { _sharedProtocol.delete(getAdmin()); } catch (Exception ignored) {} + _sharedProtocol = null; + } + } + + // Build a General (GPAT) design in /Shared so its protocol resolves from any sibling folder, with a results + // "Flag" property (conceptURI expFlag) that SetResultFlagAction requires plus a plain int result field so a + // one-row file can be imported. + private Pair createSharedAssay() throws Exception + { + Container shared = ContainerManager.getSharedContainer(); + ViewContext ctx = new ViewContext(new ViewBackgroundInfo(shared, getAdmin(), null)); + AssayDomainServiceImpl svc = new AssayDomainServiceImpl(ctx); + GWTProtocol tmpl = svc.getAssayTemplate("General"); + tmpl.setName("scoping-assay-" + getClass().getSimpleName()); + tmpl.setEditableResults(true); + + for (GWTDomain d : tmpl.getDomains()) + { + if ("Batch Fields".equals(d.getName()) || "Run Fields".equals(d.getName())) + d.getFields(true).clear(); + else if ("Data Fields".equals(d.getName())) + { + d.getFields(true).clear(); + d.getFields(true).add(new GWTPropertyDescriptor("result", "int")); + GWTPropertyDescriptor flag = new GWTPropertyDescriptor("Flag", "string"); + // SetResultFlagAction requires the target column to carry the expFlag conceptURI + flag.setConceptURI(org.labkey.api.gwt.client.ui.PropertyType.expFlag.getURI()); + d.getFields(true).add(flag); + } + } + + GWTProtocol saved = svc.saveChanges(tmpl, true); + ExpProtocol protocol = ExperimentService.get().getExpProtocol(saved.getProtocolId()); + _sharedProtocol = protocol; + return Pair.of(AssayService.get().getProvider(protocol), protocol); + } + + // Import a one-row run into container c (as the site admin) and return its assay-result RowId. + private int importOneResult(Container c, AssayProvider provider, ExpProtocol protocol) throws Exception + { + var pipeRoot = PipelineService.get().findPipelineRoot(c); + assertNotNull("Test requires a pipeline root for " + c.getPath(), pipeRoot); + File file = FileUtil.createTempFile(getClass().getSimpleName(), ".tsv", pipeRoot.getRootPath()); + Files.writeString(file.toPath(), "result\n100\n", StandardCharsets.UTF_8); + + HttpSession session = TestContext.get().getRequest().getSession(); + PipelineDataCollector.setFileCollection(session, c, protocol, List.of(Map.of(AssayDataCollector.PRIMARY_FILE, file))); + + MockMultipartHttpServletRequest req = new MockMultipartHttpServletRequest(); + req.setUserPrincipal(getAdmin()); + req.setSession(session); + AssayRunUploadForm uploadForm = new AssayRunUploadForm<>(); + uploadForm.setViewContext(new ViewContext(req, null, null)); + uploadForm.setContainer(c); + uploadForm.setUser(getAdmin()); + uploadForm.setRowId(protocol.getRowId()); + uploadForm.setName("scoping-run-" + c.getName()); + uploadForm.setDataCollectorName("Pipeline"); + + AssayRunCreator runCreator = provider.getRunCreator(); + Pair pair = runCreator.saveExperimentRun(uploadForm, null); + ExpRun run = pair.second; + + AssayProtocolSchema schema = provider.createProtocolSchema(getAdmin(), c, protocol, null); + TableInfo results = schema.getTable("Data"); + FieldKey runRowIdFk = new FieldKey(FieldKey.fromParts("Run"), "RowId"); + Map row = new TableSelector(results, Set.of("RowId"), new SimpleFilter(runRowIdFk, run.getRowId()), null).getMap(); + return (Integer) row.get("RowId"); + } + + @Test + public void testSetResultFlagRejectsForeignResultRow() throws Exception + { + Container shared = ContainerManager.getSharedContainer(); + Container folderA = createContainer("A"); + Container folderB = createContainer("B"); + Pair assay = createSharedAssay(); + AssayProvider provider = assay.first; + ExpProtocol protocol = assay.second; + + int resultRowId = importOneResult(folderB, provider, protocol); // result row lives in folder B + + // Caller can Update folder A and Read the shared design, but has NO rights in folder B + User editorA = createUserInRole(folderA, EditorRole.class); + grantRole(editorA, shared, ReaderRole.class); + + // Flagging B's result row through folder A must 404 at the per-row Folder guard (not at getProtocol). + ActionURL foreignUrl = new ActionURL(SetResultFlagAction.class, folderA) + .addParameter("rowId", String.valueOf(protocol.getRowId())) + .addParameter("providerName", provider.getName()) + .addParameter("resultRowIds", String.valueOf(resultRowId)) + .addParameter("columnName", "Flag") + .addParameter("comment", "hacked"); + assertStatus(HttpServletResponse.SC_NOT_FOUND, post(foreignUrl, editorA)); + + // Positive control: an Editor in B (with Read on the shared design) flags the same row through folder B → 200. + User editorB = createUserInRole(folderB, EditorRole.class); + grantRole(editorB, shared, ReaderRole.class); + ActionURL ownUrl = new ActionURL(SetResultFlagAction.class, folderB) + .addParameter("rowId", String.valueOf(protocol.getRowId())) + .addParameter("providerName", provider.getName()) + .addParameter("resultRowIds", String.valueOf(resultRowId)) + .addParameter("columnName", "Flag") + .addParameter("comment", "ok"); + assertStatus(HttpServletResponse.SC_OK, post(ownUrl, editorB)); + } + } } diff --git a/assay/src/org/labkey/assay/AssayModule.java b/assay/src/org/labkey/assay/AssayModule.java index e03b6e1d23d..5978d7c0d5d 100644 --- a/assay/src/org/labkey/assay/AssayModule.java +++ b/assay/src/org/labkey/assay/AssayModule.java @@ -317,6 +317,7 @@ public Set getProvisionedSchemaNames() { return Set.of( ModuleAssayCache.TestCase.class, + AssayController.ContainerScopingTestCase.class, PlateManagerTest.class, PlateSchemaTest.class ); diff --git a/assay/src/org/labkey/assay/plate/PlateManager.java b/assay/src/org/labkey/assay/plate/PlateManager.java index c62458a3b65..d9c320787ed 100644 --- a/assay/src/org/labkey/assay/plate/PlateManager.java +++ b/assay/src/org/labkey/assay/plate/PlateManager.java @@ -3224,6 +3224,17 @@ public void markHits( } else { + // Verify the caller has UpdatePermission on the container of every hit row being removed + SimpleFilter hitFilter = new SimpleFilter(FieldKey.fromParts("ResultId"), rowIds, CompareType.IN); + hitFilter.addCondition(FieldKey.fromParts("ProtocolId"), protocol.getRowId()); + Set hitContainerIds = new HashSet<>(new TableSelector(hitTable, Collections.singleton("Container"), hitFilter, null).getArrayList(String.class)); + for (String hitContainerId : hitContainerIds) + { + Container hitContainer = ContainerManager.getForId(hitContainerId); + if (hitContainer == null || !hitContainer.hasPermission(user, UpdatePermission.class)) + throw new UnauthorizedException("Failed to unmark hits. You do not have permissions to update hits in this folder."); + } + deleteHits(protocolId, rowIds); } diff --git a/core/src/org/labkey/core/CoreModule.java b/core/src/org/labkey/core/CoreModule.java index 3c862288961..fb6dddf9a93 100644 --- a/core/src/org/labkey/core/CoreModule.java +++ b/core/src/org/labkey/core/CoreModule.java @@ -1395,6 +1395,7 @@ public Set getIntegrationTests() AdminController.TestCase.class, AdminController.WorkbookDeleteTestCase.class, AdminController.ImportFolderSourceScopingTestCase.class, + AdminController.RevertFolderScopingTestCase.class, AllowListType.TestCase.class, AttachmentServiceImpl.TestCase.class, CoreController.TestCase.class, diff --git a/core/src/org/labkey/core/admin/AdminController.java b/core/src/org/labkey/core/admin/AdminController.java index 8868dcbf947..69cc3884501 100644 --- a/core/src/org/labkey/core/admin/AdminController.java +++ b/core/src/org/labkey/core/admin/AdminController.java @@ -8003,6 +8003,8 @@ public boolean handlePost(SetFolderPermissionsForm form, BindException errors) { throw new NotFoundException("An unknown project was specified to copy permissions from: " + targetProject); } + if (!source.hasPermission(getUser(), AdminPermission.class)) + throw new UnauthorizedException("You do not have permission to copy permissions from the specified project."); Map groupMap = GroupManager.copyGroupsToContainer(source, c, getUser()); //copy role assignments @@ -8425,6 +8427,9 @@ public ApiResponse execute(RevertFolderForm form, BindException errors) Container revertContainer = ContainerManager.getForPath(form.getContainerPath()); if (null != revertContainer) { + if (!revertContainer.hasPermission(getUser(), AdminPermission.class)) + throw new UnauthorizedException(); + if (revertContainer.isContainerTab()) { FolderTab tab = revertContainer.getParent().getFolderType().findTab(revertContainer.getName()); @@ -10205,6 +10210,10 @@ public ApiResponse execute(DeletedFoldersForm form, BindException errors) if (isBlank(form.getContainerPath())) throw new NotFoundException(); Container container = ContainerManager.getForPath(form.getContainerPath()); + if (container == null) + throw new NotFoundException(); + if (!container.hasPermission(getUser(), AdminPermission.class)) + throw new UnauthorizedException(); for (String tabName : form.getResurrectFolders()) { ContainerManager.clearContainerTabDeleted(container, tabName, form.getNewFolderType()); @@ -12321,4 +12330,65 @@ public void testImportFromTemplateRequiresSourceAdmin() throws Exception // Positive control performed in S3ImportTest.testS3Import(). Difficult to mock here due to pipeline job } } + + public static class RevertFolderScopingTestCase extends AbstractContainerScopingTest + { + @Test + public void testRevertFolderRequiresTargetAdmin() throws Exception + { + User admin = getAdmin(); + Container folderA = createContainer("A"); + Container folderB = createContainer("B"); + + User adminA = createUserInRole(folderA, FolderAdminRole.class); + + ActionURL foreignUrl = new ActionURL(RevertFolderAction.class, folderA) + .addParameter("containerPath", folderB.getPath()); + + // Cross-container attempt by a caller who is not an admin of the target -> 403, before any mutation. + assertStatus(HttpServletResponse.SC_FORBIDDEN, post(foreignUrl, adminA)); + + // Positive control: a site admin (who IS an admin of the target) is allowed through even cross-container, + // proving the fix re-checks AdminPermission on the target rather than locking to the request container. + // folderB is a plain folder with no container tabs, so the action makes no change and returns success:false + // at status 200 -- the point is that the guard does not reject an authorized caller. + assertStatus(HttpServletResponse.SC_OK, post(foreignUrl, admin)); + } + + @Test + public void testClearDeletedTabFoldersRequiresTargetAdmin() throws Exception + { + User admin = getAdmin(); + Container folderA = createContainer("A"); + Container folderB = createContainer("B"); + User adminA = createUserInRole(folderA, FolderAdminRole.class); + + ActionURL foreignUrl = new ActionURL(ClearDeletedTabFoldersAction.class, folderA) + .addParameter("containerPath", folderB.getPath()) + .addParameter("resurrectFolders", "anyTab"); + + // A folder admin in A only must not clear deleted-tab markers in folder B (resolved by containerPath) -> 403. + assertStatus(HttpServletResponse.SC_FORBIDDEN, post(foreignUrl, adminA)); + + // Positive control: a site admin (admin of the target) is allowed through -> 200. + assertStatus(HttpServletResponse.SC_OK, post(foreignUrl, admin)); + } + + @Test + public void testSetFolderPermissionsCopyRequiresSourceAdmin() throws Exception + { + Container dest = createContainer("Dest"); + Container source = createContainer("Source"); + User destAdmin = createUserInRole(dest, FolderAdminRole.class); + + // Copying groups/role assignments from a project the caller does not administer must be rejected. The + // action's @RequiresPermission(AdminPermission.class) only proves admin on the destination container, so a + // dest-only admin supplying another project's id as targetProject must get 403, not a copy of its security + // configuration. (Positive control omitted: a successful copy needs real project group/policy fixtures.) + ActionURL url = new ActionURL(SetFolderPermissionsAction.class, dest) + .addParameter("permissionType", "CopyExistingProject") + .addParameter("targetProject", source.getId()); + assertStatus(HttpServletResponse.SC_FORBIDDEN, post(url, destAdmin)); + } + } } diff --git a/core/src/org/labkey/core/admin/miniprofiler/MiniProfilerController.java b/core/src/org/labkey/core/admin/miniprofiler/MiniProfilerController.java index 75eae655108..05fa70c112b 100644 --- a/core/src/org/labkey/core/admin/miniprofiler/MiniProfilerController.java +++ b/core/src/org/labkey/core/admin/miniprofiler/MiniProfilerController.java @@ -243,17 +243,17 @@ public Object execute(RequestForm form, BindException errors) throw new UnauthorizedException(); RequestInfo req = MemTracker.getInstance().getRequest(form.getId()); + if (req != null && !getUser().equals(req.getUser()) && !getUser().hasApplicationAdminPermission()) + { + throw new UnauthorizedException(); + } + MemTracker.get().setViewed(getUser(), form.getId()); // Reset the X-MiniProfiler-Ids header to only include remaining unviewed (without the id we are returning) LinkedHashSet ids = new LinkedHashSet<>(MemTracker.get().getUnviewed(getUser())); getViewContext().getResponse().setHeader("X-MiniProfiler-Ids", ids.toString()); - if (req != null && !getUser().equals(req.getUser()) && !getUser().hasApplicationAdminPermission()) - { - throw new UnauthorizedException(); - } - return req; } } diff --git a/experiment/src/org/labkey/experiment/controllers/exp/ExperimentController.java b/experiment/src/org/labkey/experiment/controllers/exp/ExperimentController.java index 3e12c530585..766bdaf1477 100644 --- a/experiment/src/org/labkey/experiment/controllers/exp/ExperimentController.java +++ b/experiment/src/org/labkey/experiment/controllers/exp/ExperimentController.java @@ -150,6 +150,7 @@ import org.labkey.api.exp.xar.LsidUtils; import org.labkey.api.files.FileContentService; import org.labkey.api.gwt.client.AuditBehaviorType; +import org.labkey.api.gwt.client.model.GWTPropertyDescriptor; import org.labkey.api.inventory.InventoryService; import org.labkey.api.module.ModuleHtmlView; import org.labkey.api.module.ModuleLoader; @@ -201,7 +202,10 @@ import org.labkey.api.security.permissions.SiteAdminPermission; import org.labkey.api.security.permissions.TroubleshooterPermission; import org.labkey.api.security.permissions.UpdatePermission; +import org.labkey.api.security.roles.EditorRole; +import org.labkey.api.security.roles.FolderAdminRole; import org.labkey.api.security.roles.ReaderRole; +import org.labkey.api.security.roles.Role; import org.labkey.api.settings.AppProps; import org.labkey.api.settings.ConceptURIProperties; import org.labkey.api.sql.LabKeySql; @@ -3754,6 +3758,10 @@ protected void deleteObjects(DeleteForm form) { for (ExpProtocol protocol : getProtocolsForDeletion(form)) { + // Re-check here - cannot delete a run-less assay design owned by another container via a forged rowId. + if (!protocol.getContainer().hasPermission(getUser(), DesignAssayPermission.class)) + throw new UnauthorizedException("You do not have sufficient permissions to delete this assay design."); + protocol.delete(getUser(), form.getUserComment()); } } @@ -4715,6 +4723,12 @@ public void validateCommand(ExperimentForm target, Errors errors) @Override public boolean handlePost(ExperimentForm form, BindException errors) throws Exception { + // Confirm the run group actually lives in this container before updating, like the GET sibling ShowUpdateAction. + Experiment bean = form.getBean(); + ExpExperiment exp = bean == null ? null : ExperimentService.get().getExpExperiment(bean.getRowId()); + if (exp == null || !getContainer().equals(exp.getContainer())) + throw new NotFoundException("Run group not found in this folder"); + form.doUpdate(); form.refreshFromDb(); _exp = form.getBean(); @@ -5281,7 +5295,19 @@ public void validateCommand(ExperimentRunListForm target, Errors errors) @Override public boolean handlePost(ExperimentRunListForm form, BindException errors) { - addSelectedRunsToExperiment(form.lookupExperiment(), form.getDataRegionSelectionKey()); + ExpExperiment exp = form.lookupExperiment(); + if (exp == null || !exp.getContainer().hasPermission(getUser(), InsertPermission.class)) + throw new NotFoundException("Could not find run group with RowId " + form.getExpRowId()); + + List runs = new ArrayList<>(); + for (int runId : DataRegionSelection.getSelectedIntegers(getViewContext(), form.getDataRegionSelectionKey(), true)) + { + ExpRun run = ExperimentService.get().getExpRun(runId); + if (run == null || !run.getContainer().hasPermission(getUser(), InsertPermission.class)) + throw new NotFoundException("Could not find run with RowId " + runId); + runs.add(run); + } + exp.addRuns(getUser(), runs.toArray(new ExpRun[0])); return true; } @@ -6022,9 +6048,9 @@ else if (in.rowId > 0) errors.reject(ERROR_MSG, "Can't resolve sample '" + in.rowId + "'"); } - if (m == null) + if (m == null || !m.getContainer().hasPermission(getUser(), ReadPermission.class)) { - errors.reject(ERROR_MSG, "Material input lsid or rowId required"); + errors.reject(ERROR_MSG, "Material input couldn't be resolved"); continue; } @@ -6064,9 +6090,9 @@ else if (in.rowId > 0) errors.reject(ERROR_MSG, "Can't resolve data '" + in.rowId + "'"); } - if (d == null) + if (d == null || !d.getContainer().hasPermission(getUser(), ReadPermission.class)) { - errors.reject(ERROR_MSG, "Data input lsid or rowId required"); + errors.reject(ERROR_MSG, "Data input couldn't be resolved"); continue; } @@ -6089,9 +6115,8 @@ else if (in.rowId > 0) ExpSampleType outSampleType; if (form.targetSampleType != null) { - // TODO: check in scope and has permission outSampleType = SampleTypeService.get().getSampleType(form.targetSampleType.toString()); - if (outSampleType == null) + if (outSampleType == null || !outSampleType.getContainer().hasPermission(getUser(), ReadPermission.class)) errors.reject(ERROR_MSG, "Sample type not found: " + form.targetSampleType.toString()); } else @@ -6102,9 +6127,8 @@ else if (in.rowId > 0) ExpDataClass outDataClass; if (form.targetDataClass != null) { - // TODO: check in scope and has permission outDataClass = ExperimentServiceImpl.get().getDataClass(form.targetDataClass.toString()); - if (outDataClass == null) + if (outDataClass == null || !outDataClass.getContainer().hasPermission(getUser(), ReadPermission.class)) errors.reject(ERROR_MSG, "DataClass not found: " + form.targetDataClass.toString()); } else @@ -6585,10 +6609,11 @@ public boolean handlePost(MoveRunsForm form, BindException errors) for (Integer runId : runIds) { ExpRun run = ExperimentService.get().getExpRun(runId); - if (run != null) + if (run == null || !run.getContainer().equals(getContainer())) { - runs.add(run); + throw new NotFoundException("Could not find run with RowId " + runId + " in this folder"); } + runs.add(run); } ViewBackgroundInfo info = getViewBackgroundInfo(); @@ -8027,6 +8052,9 @@ public Object execute(EntitySequenceForm form, BindException errors) ExpSampleType sampleType = SampleTypeService.get().getSampleType(form.getRowId()); if (sampleType != null) { + if (!sampleType.getContainer().hasPermission(getUser(), DesignSampleTypePermission.class)) + throw new UnauthorizedException("Insufficient permissions."); + sampleType.ensureMinGenId(form.getNewValue()); domain = sampleType.getDomain(); } @@ -8412,5 +8440,328 @@ public void testDataClassAttachmentContainerScoping() throws Exception JSONObject json = new JSONObject(resp.getContentAsString()); assertEquals("No ExpData found for id: " + data.getRowId(), json.get("exception")); } + + @Test + public void testUpdateRunGroupContainerScoping() throws Exception + { + User admin = getAdmin(); + Container folderA = createContainer("A"); + Container folderB = createContainer("B"); + + // A run group (Experiment) that lives in folder B + ExpExperiment runGroup = ExperimentService.get().createExpExperiment(folderB, "scoping-test-run-group"); + runGroup.save(admin); + int rowId = runGroup.getRowId(); + + // A caller who can Update folder A (Editor) but has no rights in folder B + User editorA = createUserInRole(folderA, EditorRole.class); + + // Updating B's run group through folder A must 404 rather than overwrite/re-home it. The action's + // @RequiresPermission(UpdatePermission.class) passes in folder A, so without the handlePost guard the + // unscoped doUpdate() would edit B's row and rewrite its container to A. + ActionURL foreignUrl = new ActionURL(UpdateAction.class, folderA) + .addParameter("RowId", String.valueOf(rowId)) + .addParameter("Name", "hacked"); + assertStatus(HttpServletResponse.SC_NOT_FOUND, post(foreignUrl, editorA)); + // A site admin, who CAN update folder B, still gets 404 through folder A (no cross-container write). + assertStatus(HttpServletResponse.SC_NOT_FOUND, post(foreignUrl, admin)); + + // The run group must be untouched in its own container + ExpExperiment after = ExperimentService.get().getExpExperiment(rowId); + assertNotNull("Run group must still exist", after); + assertEquals("Name must be unchanged after a cross-container update", "scoping-test-run-group", after.getName()); + assertEquals("Container must be unchanged after a cross-container update", folderB, after.getContainer()); + + // Positive control: updating through its own container succeeds (302 redirect to the success URL) and applies. + ActionURL ownUrl = new ActionURL(UpdateAction.class, folderB) + .addParameter("RowId", String.valueOf(rowId)) + .addParameter("Name", "renamed"); + assertStatus(HttpServletResponse.SC_FOUND, post(ownUrl, admin)); + assertEquals("Name should be updated by a same-container request", "renamed", + ExperimentService.get().getExpExperiment(rowId).getName()); + } + + @Test + public void testAddRunsToExperimentContainerScoping() throws Exception + { + User admin = getAdmin(); + Container folderA = createContainer("A"); + Container folderB = createContainer("B"); + + // A run group (Experiment) that lives in folder B + ExpExperiment runGroup = ExperimentService.get().createExpExperiment(folderB, "scoping-test-add-runs"); + runGroup.save(admin); + int expRowId = runGroup.getRowId(); + + // A caller who can Insert in folder A but has no rights in folder B + User editorA = createUserInRole(folderA, EditorRole.class); + + // Adding runs to B's run group through folder A must 404: the run group is resolved by a global RowId and + // ExpExperimentImpl.addRuns does a raw INSERT with no authorization, so without the handlePost guard a + // forged expRowId would let a folder-A user mutate a foreign run group. + ActionURL foreignUrl = new ActionURL(AddRunsToExperimentAction.class, folderA) + .addParameter("expRowId", String.valueOf(expRowId)); + assertStatus(HttpServletResponse.SC_NOT_FOUND, post(foreignUrl, editorA)); + + // Positive control: addressing the run group through its own container passes the guard. No runs are + // selected, so the action makes no change and redirects to the group's details page (302). + ActionURL ownUrl = new ActionURL(AddRunsToExperimentAction.class, folderB) + .addParameter("expRowId", String.valueOf(expRowId)); + assertStatus(HttpServletResponse.SC_FOUND, post(ownUrl, admin)); + } + + @Test + public void testMoveRunsContainerScoping() throws Exception + { + Container folderA = createContainer("A"); + Container folderB = createContainer("B"); + + // A run that lives in folder B + ExpRun run = createRun(folderB, "scoping-test-move-run"); + int runId = run.getRowId(); + + // A caller with Insert+Delete in folder A (Editor) but no rights in folder B + User editorA = createUserInRole(folderA, EditorRole.class); + + // MoveRuns is scoped to getContainer() as the source and only checks Insert on the target; runs are resolved + // from the client-supplied selection by global RowId. Moving B's run via folder A (as both source and + // target) must 404 because the run does not live in the source container the caller is operating in. + ActionURL foreignUrl = new ActionURL(MoveRunsAction.class, folderA) + .addParameter("targetContainerId", folderA.getId()) + .addParameter(DataRegion.SELECT_CHECKBOX_NAME, String.valueOf(runId)); + assertStatus(HttpServletResponse.SC_NOT_FOUND, post(foreignUrl, editorA)); + + // The run must remain in folder B + ExpRun after = ExperimentService.get().getExpRun(runId); + assertNotNull("Run must still exist", after); + assertEquals("Run must not have been moved out of its container", folderB, after.getContainer()); + + // Positive control: a successful move queues a MoveRunsPipelineJob, which is exercised by existing run-move + // tests; this case verifies only that the cross-container request is rejected before any job is queued. + } + + @Test + public void testDeleteProtocolByRowIdsContainerScoping() throws Exception + { + Container folderA = createContainer("A"); + Container folderB = createContainer("B"); + + // A run-less protocol (assay design) that lives in folder B. Run-less so deleteProtocolByRowIds skips its + // AdminPermission check, leaving the per-protocol DesignAssay guard in deleteObjects as the only gate. + ExpProtocol protocol = ExperimentService.get().createExpProtocol(folderB, ExpProtocol.ApplicationType.ExperimentRun, "scoping-test-protocol"); + protocol.save(getAdmin()); + int rowId = protocol.getRowId(); + + // A caller who can design assays in folder A only. DesignAssayPermission's role lives in the assay module, + // resolved by name here to avoid a compile-time dependency from the experiment module. + @SuppressWarnings("unchecked") + Class assayDesigner = (Class) Class.forName("org.labkey.assay.security.AssayDesignerRole"); + User designerA = createUserInRole(folderA, assayDesigner); + + // Force-deleting B's protocol through folder A must be rejected. The forceDelete POST path runs handlePost + // -> deleteObjects directly, bypassing the getView container check, so the deleteObjects guard is what stops + // it (403 for an authenticated caller lacking DesignAssay on the protocol's own container). + ActionURL foreignUrl = new ActionURL(DeleteProtocolByRowIdsAction.class, folderA) + .addParameter("forceDelete", "true") + .addParameter("singleObjectRowId", String.valueOf(rowId)); + assertStatus(HttpServletResponse.SC_FORBIDDEN, post(foreignUrl, designerA)); + + // The protocol must still exist in folder B + assertNotNull("Protocol must not have been deleted cross-container", ExperimentService.get().getExpProtocol(rowId)); + + // Positive control: once the caller is granted design rights in folder B, the same forceDelete through + // folder B succeeds (302) and removes the protocol -- proving the guard rejects only the cross-container case. + grantRole(designerA, folderB, assayDesigner); + ActionURL ownUrl = new ActionURL(DeleteProtocolByRowIdsAction.class, folderB) + .addParameter("forceDelete", "true") + .addParameter("singleObjectRowId", String.valueOf(rowId)); + assertStatus(HttpServletResponse.SC_FOUND, post(ownUrl, designerA)); + assertNull("Protocol should be deleted by a same-container request", ExperimentService.get().getExpProtocol(rowId)); + } + + @Test + public void testSetEntitySequenceContainerScoping() throws Exception + { + Container folderA = createContainer("A"); + Container folderB = createContainer("B"); + + // A sample type that lives in folder B + List props = List.of(new GWTPropertyDescriptor("name", "string")); + ExpSampleType sampleType = SampleTypeService.get().createSampleType(folderB, getAdmin(), "scoping-test-st", null, + props, Collections.emptyList(), -1, -1, -1, -1, null, null); + int rowId = sampleType.getRowId(); + + // A folder admin in A only (has DesignSampleType in A via FolderAdminRole, no rights in B) + User adminA = createUserInRole(folderA, FolderAdminRole.class); + + // Advancing the genId of B's sample type through folder A must 403. The request-container DesignSampleType + // check passes in A, but ensureMinGenId operates on the type's OWN container, so the per-object check rejects it. + ActionURL foreignUrl = new ActionURL(SetEntitySequenceAction.class, folderA) + .addParameter("kindName", SampleTypeDomainKind.NAME) + .addParameter("seqType", NameGenerator.EntityCounter.genId.name()) + .addParameter("rowId", String.valueOf(rowId)) + .addParameter("newValue", "100"); + assertStatus(HttpServletResponse.SC_FORBIDDEN, post(foreignUrl, adminA)); + + // Positive control: a folder admin in B can advance the sequence through folder B (success, 200). + User adminB = createUserInRole(folderB, FolderAdminRole.class); + ActionURL ownUrl = new ActionURL(SetEntitySequenceAction.class, folderB) + .addParameter("kindName", SampleTypeDomainKind.NAME) + .addParameter("seqType", NameGenerator.EntityCounter.genId.name()) + .addParameter("rowId", String.valueOf(rowId)) + .addParameter("newValue", "100"); + assertStatus(HttpServletResponse.SC_OK, post(ownUrl, adminB)); + } + + @Test + public void testDeriveActionMaterialContainerScoping() throws Exception + { + Container folderA = createContainer("A"); + Container folderB = createContainer("B"); + + // Editor in folder A only: holds the InsertPermission the action requires in A, but has no rights in folder B. + User editorA = createUserInRole(folderA, EditorRole.class); + + // A sample type with one sample in each folder. The editor can read its own folder A but not folder B. + ExpSampleType stA = createSampleType(folderA, "DeriveScopeStA"); + ExpSampleType stB = createSampleType(folderB, "DeriveScopeStB"); + ExpMaterial sampleA = createSample(folderA, stA, "srcA"); + ExpMaterial sampleB = createSample(folderB, stB, "srcB"); + + ActionURL url = new ActionURL(DeriveAction.class, folderA); + + // Negative (input): a material input resolved by global rowId that lives in folder B must not be usable as a + // derivation parent by a caller who cannot read B. Without the per-input Read check the foreign sample would + // be silently consumed (an IDOR). The output target is in folder A, so only the foreign input can be at fault. + JSONObject foreignInput = new JSONObject() + .put("materialInputs", new JSONArray().put(new JSONObject().put("rowId", sampleB.getRowId()))) + .put("targetSampleType", stA.getLSID()) + .put("materialOutputCount", 1); + MockHttpServletResponse resp = postJson(url, editorA, foreignInput); + assertStatus(HttpServletResponse.SC_BAD_REQUEST, resp); + assertTrue("Expected a material-input scope rejection, was: " + resp.getContentAsString(), + resp.getContentAsString().contains("Material input couldn't be resolved")); + + // Negative (target): deriving INTO a sample type the caller cannot read must be rejected as "not found", so + // the caller can't probe which foreign sample types exist by their LSID. + JSONObject foreignTarget = new JSONObject() + .put("targetSampleType", stB.getLSID()) + .put("materialOutputCount", 1); + resp = postJson(url, editorA, foreignTarget); + assertStatus(HttpServletResponse.SC_BAD_REQUEST, resp); + assertTrue("Expected a target sample type scope rejection, was: " + resp.getContentAsString(), + resp.getContentAsString().contains("Sample type not found")); + + // Positive control: the same request shape with the input and target both in folder A -- which the editor + // can read and insert into -- succeeds and actually derives a new sample, proving the checks reject only the + // cross-container case rather than every request. + JSONObject ok = new JSONObject() + .put("materialInputs", new JSONArray().put(new JSONObject().put("rowId", sampleA.getRowId()))) + .put("targetSampleType", stA.getLSID()) + .put("materialOutputs", new JSONArray().put(new JSONObject().put("values", new JSONObject().put("name", "derivedA")))); + resp = postJson(url, editorA, ok); + assertStatus(HttpServletResponse.SC_OK, resp); + assertTrue("Derivation should report success, was: " + resp.getContentAsString(), + new JSONObject(resp.getContentAsString()).getBoolean("success")); + assertNotNull("A new sample should have been derived in folder A", stA.getSample(folderA, "derivedA")); + } + + @Test + public void testDeriveActionDataContainerScoping() throws Exception + { + Container folderA = createContainer("A"); + Container folderB = createContainer("B"); + + // Editor in folder A only: holds the InsertPermission the action requires in A, but has no rights in folder B. + User editorA = createUserInRole(folderA, EditorRole.class); + + // A data class with one data object in each folder. The editor can read its own folder A but not folder B. + ExpDataClass dcA = createDataClass(folderA, "DeriveScopeDcA"); + ExpDataClass dcB = createDataClass(folderB, "DeriveScopeDcB"); + ExpData dataA = createData(folderA, dcA, "srcDataA"); + ExpData dataB = createData(folderB, dcB, "srcDataB"); + + ActionURL url = new ActionURL(DeriveAction.class, folderA); + + // Negative (input): a data input resolved by global rowId that lives in folder B must not be usable as a + // derivation parent by a caller who cannot read B. The Read check fires before the data-class membership + // check, so a foreign data object is rejected as unresolvable rather than silently consumed (an IDOR). + JSONObject foreignInput = new JSONObject() + .put("dataInputs", new JSONArray().put(new JSONObject().put("rowId", dataB.getRowId()))) + .put("targetDataClass", dcA.getLSID()) + .put("dataOutputCount", 1); + MockHttpServletResponse resp = postJson(url, editorA, foreignInput); + assertStatus(HttpServletResponse.SC_BAD_REQUEST, resp); + assertTrue("Expected a data-input scope rejection, was: " + resp.getContentAsString(), + resp.getContentAsString().contains("Data input couldn't be resolved")); + + // Negative (target): deriving INTO a data class the caller cannot read must be rejected as "not found", so + // the caller can't probe which foreign data classes exist by their LSID. + JSONObject foreignTarget = new JSONObject() + .put("targetDataClass", dcB.getLSID()) + .put("dataOutputCount", 1); + resp = postJson(url, editorA, foreignTarget); + assertStatus(HttpServletResponse.SC_BAD_REQUEST, resp); + assertTrue("Expected a target data class scope rejection, was: " + resp.getContentAsString(), + resp.getContentAsString().contains("DataClass not found")); + + // Positive control: the same request shape with the input and target both in folder A -- which the editor + // can read and insert into -- succeeds and actually derives a new data object, proving the checks reject + // only the cross-container case rather than every request. + JSONObject ok = new JSONObject() + .put("dataInputs", new JSONArray().put(new JSONObject().put("rowId", dataA.getRowId()))) + .put("targetDataClass", dcA.getLSID()) + .put("dataOutputs", new JSONArray().put(new JSONObject().put("values", new JSONObject().put("name", "derivedDataA")))); + resp = postJson(url, editorA, ok); + assertStatus(HttpServletResponse.SC_OK, resp); + assertTrue("Derivation should report success, was: " + resp.getContentAsString(), + new JSONObject(resp.getContentAsString()).getBoolean("success")); + assertNotNull("A new data object should have been derived in folder A", ExperimentService.get().getExpData(dcA, "derivedDataA")); + } + + // Create a sample type with a single string "name" property, mirroring the idiom in testSetEntitySequenceContainerScoping. + private ExpSampleType createSampleType(Container c, String name) throws Exception + { + List props = List.of(new GWTPropertyDescriptor("name", "string")); + return SampleTypeService.get().createSampleType(c, getAdmin(), name, null, props, Collections.emptyList(), -1, -1, -1, -1, null, null); + } + + // Create a saved sample in the given sample type, mirroring the sample-creation idiom in LineageTest. + private ExpMaterial createSample(Container c, ExpSampleType st, String name) throws Exception + { + ExpMaterial m = ExperimentService.get().createExpMaterial(c, st.generateSampleLSID().setObjectId(name).toString(), name); + m.setCpasType(st.getLSID()); + m.save(getAdmin()); + return m; + } + + // Create a data class with a single string "name" property, mirroring the idiom in LineageTest. + private ExpDataClass createDataClass(Container c, String name) throws Exception + { + List props = List.of(new GWTPropertyDescriptor("name", "string")); + return ExperimentServiceImpl.get().createDataClass(c, getAdmin(), name, null, props, Collections.emptyList(), null, null); + } + + // Insert a single named row into the data class and return the resulting ExpData. + private ExpData createData(Container c, ExpDataClass dc, String name) throws Exception + { + UserSchema dataSchema = new ExpSchema(getAdmin(), c).getUserSchema(ExpSchema.NestedSchemas.data.name()); + BatchValidationException errors = new BatchValidationException(); + dataSchema.getTable(dc.getName()).getUpdateService() + .insertRows(getAdmin(), c, List.of(CaseInsensitiveHashMap.of("name", name)), errors, null, null); + if (errors.hasErrors()) + throw errors; + return ExperimentService.get().getExpData(dc, name); + } + + // Create a minimal saved experiment run in the given container, mirroring the run-creation idiom in LineageTest. + private ExpRun createRun(Container c, String name) throws Exception + { + ExpRun run = ExperimentService.get().createExperimentRun(c, name); + run.setFilePathRoot(PipelineService.get().findPipelineRoot(c).getRootPath()); + run.setProtocol(ExperimentService.get().ensureSampleDerivationProtocol(getAdmin())); + return ExperimentService.get().saveSimpleExperimentRun(run, Map.of(), Map.of(), Map.of(), Map.of(), Map.of(), + new ViewBackgroundInfo(c, getAdmin(), null), null, false); + } } } diff --git a/mothership/src/org/labkey/mothership/MothershipController.java b/mothership/src/org/labkey/mothership/MothershipController.java index d5b8579530c..3d4e59cbc23 100644 --- a/mothership/src/org/labkey/mothership/MothershipController.java +++ b/mothership/src/org/labkey/mothership/MothershipController.java @@ -575,22 +575,6 @@ public void validateCommand(ServerInstallationForm target, Errors errors) @Override public boolean handlePost(ServerInstallationForm form, BindException errors) throws Exception { - // Confirm the row belongs to the current container - Object pk = form.getPkVal(); - if (pk == null) - throw new NotFoundException("No server installation specified"); - int installationId; - try - { - installationId = Integer.parseInt(String.valueOf(pk)); - } - catch (NumberFormatException e) - { - throw new NotFoundException("Invalid server installation id: " + pk); - } - if (MothershipManager.get().getServerInstallation(installationId, getContainer()) == null) - throw new NotFoundException("Server installation not found in this folder"); - form.doUpdate(); return true; } @@ -2030,6 +2014,43 @@ public void testUpdateInstallationContainerScoping() throws Exception assertStatus(HttpServletResponse.SC_FOUND, post(ownUrl, admin)); assertEquals("Note should have been updated through the row's own container", "updated", MothershipManager.get().getServerInstallation(id, folderB).getNote()); } + + @Test + public void testUpdateStackTraceContainerScoping() throws Exception + { + User admin = getAdmin(); + Container folderA = createContainer("A"); + Container folderB = createContainer("B"); + + // An exception stack trace that lives in folder B (StackTraceHash is derived from the stack trace text) + ExceptionStackTrace st = new ExceptionStackTrace(); + st.setContainer(folderB.getId()); + st.setStackTrace("java.lang.NullPointerException\n\tat org.labkey.scoping.Test.run(Test.java:1)\n"); + st.setComments("original"); + st = Table.insert(admin, MothershipManager.get().getTableInfoExceptionStackTrace(), st); + int id = st.getExceptionStackTraceId(); + + // Updating it through folder A must 404 rather than overwrite/re-home it. doUpdate() keys Table.update on + // the id alone and rewrites the container, so without the handlePost guard a site admin (who CAN update + // folder B) would edit B's row through folder A and re-home it. + ActionURL url = new ActionURL(UpdateStackTraceAction.class, folderA) + .addParameter("ExceptionStackTraceId", String.valueOf(id)) + .addParameter("Comments", "hacked"); + assertStatus(HttpServletResponse.SC_NOT_FOUND, post(url, admin)); + + // The row in folder B must be untouched + ExceptionStackTrace reloaded = MothershipManager.get().getExceptionStackTrace(id, folderB); + assertNotNull("Stack trace should still exist in its own container", reloaded); + assertEquals("Comments must not have been overwritten", "original", reloaded.getComments()); + + // Positive control: updating through the row's own container (folder B) succeeds and persists the change, + // proving the guard rejects only the cross-container case, not every update. + ActionURL ownUrl = new ActionURL(UpdateStackTraceAction.class, folderB) + .addParameter("ExceptionStackTraceId", String.valueOf(id)) + .addParameter("Comments", "updated"); + assertStatus(HttpServletResponse.SC_FOUND, post(ownUrl, admin)); + assertEquals("Comments should have been updated through the row's own container", "updated", MothershipManager.get().getExceptionStackTrace(id, folderB).getComments()); + } } } diff --git a/pipeline/src/org/labkey/pipeline/PipelineController.java b/pipeline/src/org/labkey/pipeline/PipelineController.java index bb3f466f0c5..73269af8da1 100644 --- a/pipeline/src/org/labkey/pipeline/PipelineController.java +++ b/pipeline/src/org/labkey/pipeline/PipelineController.java @@ -15,9 +15,11 @@ */ package org.labkey.pipeline; +import jakarta.servlet.http.HttpServletResponse; import org.apache.commons.lang3.StringUtils; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.junit.Test; import org.json.JSONArray; import org.json.JSONObject; import org.labkey.api.action.ApiJsonForm; @@ -46,6 +48,7 @@ import org.labkey.api.data.ContainerFilter; import org.labkey.api.data.ContainerManager; import org.labkey.api.data.SimpleFilter; +import org.labkey.api.data.Table; import org.labkey.api.data.TableSelector; import org.labkey.api.exp.property.DomainUtil; import org.labkey.api.files.FileContentService; @@ -64,6 +67,7 @@ import org.labkey.api.pipeline.PipelineStatusUrls; import org.labkey.api.pipeline.PipelineUrls; import org.labkey.api.pipeline.browse.PipelinePathForm; +import org.labkey.api.pipeline.file.FileAnalysisTaskPipeline; import org.labkey.api.pipeline.view.SetupForm; import org.labkey.api.query.FieldKey; import org.labkey.api.query.QueryUrls; @@ -77,11 +81,13 @@ 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.AbstractContainerScopingTest; import org.labkey.api.security.permissions.AdminOperationsPermission; import org.labkey.api.security.permissions.AdminPermission; import org.labkey.api.security.permissions.DeletePermission; import org.labkey.api.security.permissions.ReadPermission; import org.labkey.api.security.permissions.UserManagementPermission; +import org.labkey.api.security.roles.FolderAdminRole; import org.labkey.api.security.roles.Role; import org.labkey.api.security.roles.RoleManager; import org.labkey.api.settings.AdminConsole; @@ -89,6 +95,7 @@ import org.labkey.api.trigger.TriggerConfiguration; import org.labkey.api.util.DateUtil; import org.labkey.api.util.FileUtil; +import org.labkey.api.util.GUID; import org.labkey.api.util.HtmlStringBuilder; import org.labkey.api.util.LinkBuilder; import org.labkey.api.util.NetworkDrive; @@ -117,6 +124,7 @@ import org.labkey.pipeline.api.PipelineStatusManager; import org.labkey.pipeline.status.StatusController; import org.springframework.beans.MutablePropertyValues; +import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.validation.BindException; import org.springframework.validation.Errors; import org.springframework.web.servlet.ModelAndView; @@ -1551,6 +1559,15 @@ public static class SavePipelineTriggerAction extends MutatingApiAction getIntegrationTests() PipelineServiceImpl.TestCase.class, StatusController.TestCase.class, StatusController.ContainerScopingTestCase.class, + PipelineController.ContainerScopingTestCase.class, ClusterStartup.TestCase.class ); } diff --git a/pipeline/src/org/labkey/pipeline/api/PipelineStatusManager.java b/pipeline/src/org/labkey/pipeline/api/PipelineStatusManager.java index e0b34abe367..78dc459a7c0 100644 --- a/pipeline/src/org/labkey/pipeline/api/PipelineStatusManager.java +++ b/pipeline/src/org/labkey/pipeline/api/PipelineStatusManager.java @@ -690,10 +690,13 @@ public static void completeStatus(User user, Collection rowIds) if (!statusSet) { - // Fall back to updating the simple bean in the case where can can't deserialize the job itself + // Fall back to updating the simple bean in the case where we can't deserialize the job itself PipelineStatusFileImpl sf = PipelineStatusManager.getStatusFile(rowId); if (sf != null) { + Container c = sf.lookupContainer(); + if (c == null || !c.hasPermission(user, UpdatePermission.class)) + throw new UnauthorizedException(); LOG.info("Job " + sf.getFilePath() + " was marked as complete by " + user); sf.setStatus(PipelineJob.TaskStatus.complete.toString()); sf.setInfo(null); diff --git a/pipeline/src/org/labkey/pipeline/query/TriggerConfigurationsTable.java b/pipeline/src/org/labkey/pipeline/query/TriggerConfigurationsTable.java index 009a03b5061..d5b33d4ac72 100644 --- a/pipeline/src/org/labkey/pipeline/query/TriggerConfigurationsTable.java +++ b/pipeline/src/org/labkey/pipeline/query/TriggerConfigurationsTable.java @@ -320,7 +320,10 @@ protected Map deleteRow(User user, Container container, Map deleteRow = super.deleteRow(user, container, oldRowMap); // call the stop() method for this config if it was successfully deleted diff --git a/pipeline/src/org/labkey/pipeline/status/StatusController.java b/pipeline/src/org/labkey/pipeline/status/StatusController.java index 94965407a05..1e5050799a8 100644 --- a/pipeline/src/org/labkey/pipeline/status/StatusController.java +++ b/pipeline/src/org/labkey/pipeline/status/StatusController.java @@ -60,9 +60,11 @@ import org.labkey.api.security.permissions.ReadPermission; import org.labkey.api.security.permissions.TroubleshooterPermission; import org.labkey.api.security.permissions.UpdatePermission; +import org.labkey.api.security.roles.EditorRole; import org.labkey.api.security.roles.ReaderRole; import org.labkey.api.settings.AdminConsole; import org.labkey.api.util.FileUtil; +import org.labkey.api.util.GUID; import org.labkey.api.util.HtmlString; import org.labkey.api.util.NetworkDrive; import org.labkey.api.util.PageFlowUtil; @@ -97,6 +99,7 @@ import java.nio.file.InvalidPathException; import java.nio.file.Path; import java.util.Date; +import java.util.List; import java.util.Set; import java.util.TreeSet; @@ -397,8 +400,12 @@ public ModelAndView getView(RowIdForm form, BindException errors) if (!getContainer().equals(_statusFile.lookupContainer())) { + Container target = _statusFile.lookupContainer(); + // Only redirect if the user can read the job's container; otherwise don't reveal that it exists + if (target == null || !target.hasPermission(getUser(), ReadPermission.class)) + throw new NotFoundException("Could not find status file for rowId " + form.getRowId()); ActionURL url = getViewContext().cloneActionURL(); - url.setContainer(_statusFile.lookupContainer()); + url.setContainer(target); throw new RedirectException(url); } @@ -494,15 +501,20 @@ public ActionURL getRedirectURL(RowIdForm form) Container c = getContainerCheckAdmin(); PipelineStatusFile sf = getStatusFile(form.getRowId()); + if (sf == null) throw new NotFoundException("Could not find status file for rowId " + form.getRowId()); + Container sfContainer = sf.lookupContainer(); + // Only navigate to the job's container if the user can read it + if (sfContainer == null || !sfContainer.hasPermission(getUser(), ReadPermission.class)) + throw new NotFoundException("Could not find status file for rowId " + form.getRowId()); if (sf.getDataUrl() != null) { throw new RedirectException(sf.getDataUrl()); } - return urlDetails(c, form.getRowId()); + return urlDetails(sfContainer, form.getRowId()); } } @@ -517,8 +529,12 @@ public ActionURL getRedirectURL(RowIdForm form) if (c == null || c.isRoot()) { PipelineStatusFileImpl sf = getStatusFile(form.getRowId()); - if (sf.getContainerId() != null) - c = ContainerManager.getForId(sf.getContainerId()); + if (sf == null) + throw new NotFoundException("Could not find status file for rowId " + form.getRowId()); + Container sfContainer = sf.lookupContainer(); + // Only navigate to the job's container if the user can read it; otherwise don't reveal it exists + if (sfContainer != null && sfContainer.hasPermission(getUser(), ReadPermission.class)) + c = sfContainer; } if (c != null) @@ -550,7 +566,8 @@ public void render(ShowFileForm form, BindException errors, PrintWriter out) thr String fileName; PipelineStatusFile sf = getStatusFile(form.getRowId()); - if (sf != null) + // Resolved by global rowId; only serve files for a job that belongs to the current container + if (sf != null && getContainer().equals(sf.lookupContainer())) { fileName = form.getFilename(); @@ -924,8 +941,12 @@ public boolean handlePost(RowIdForm form, BindException errors) for (Integer rowId : rowIds) { var sf = getStatusFile(rowId); + // Resolved by global rowId; reject a job that belongs to another container if (sf == null) throw new NotFoundException("Could not find status file for rowId " + form.getRowId()); + Container sfContainer = sf.lookupContainer(); + if (sfContainer == null || !sfContainer.hasPermission(getUser(), UpdatePermission.class)) + throw new NotFoundException("Could not find status file for rowId " + form.getRowId()); if (firstDetailsURL == null) firstDetailsURL = urlDetails(sf); @@ -1094,13 +1115,7 @@ public void testStatusDetailsContainerScoping() throws Exception Container folderB = createContainer("B"); User readerA = createUserInRole(folderA, ReaderRole.class); - // A status file that lives in folder B. FilePath is a required column; point it at a non-existent log so - // StatusDetailsBean skips reading it (it only reads when the file exists) without affecting the scoping check. - PipelineStatusFileImpl sf = new PipelineStatusFileImpl(); - sf.beforeInsert(admin, folderB.getId()); - sf.setStatus(PipelineJob.TaskStatus.complete.toString()); - sf.setFilePath(FileUtil.appendName(FileUtil.getTempDirectory(), "pipeline-scoping-test-" + folderB.getRowId() + ".log").getAbsolutePath()); - sf = Table.insert(admin, PipelineSchema.getInstance().getTableInfoStatusFiles(), sf); + PipelineStatusFileImpl sf = insertStatusFile(folderB, PipelineJob.TaskStatus.complete.toString()); long rowId = sf.getRowId(); ActionURL foreignUrl = new ActionURL(StatusDetailsAction.class, folderA).addParameter("rowId", String.valueOf(rowId)); @@ -1117,5 +1132,115 @@ public void testStatusDetailsContainerScoping() throws Exception ActionURL ownUrl = new ActionURL(StatusDetailsAction.class, folderB).addParameter("rowId", String.valueOf(rowId)); assertStatus(HttpServletResponse.SC_OK, get(ownUrl, admin)); } + + @Test + public void testRetryStatusContainerScoping() throws Exception + { + Container folderA = createContainer("A"); + Container folderB = createContainer("B"); + + // A job that lives in folder B + long rowId = insertStatusFile(folderB, PipelineJob.TaskStatus.error.toString()).getRowId(); + + // A caller who can Update folder A (Editor) but has no rights in folder B + User editorA = createUserInRole(folderA, EditorRole.class); + + // Retrying B's job through folder A must 404 rather than re-queue a job in a container the caller can't touch. + ActionURL foreignUrl = new ActionURL(RetryStatusAction.class, folderA).addParameter("rowId", String.valueOf(rowId)); + assertStatus(HttpServletResponse.SC_NOT_FOUND, post(foreignUrl, editorA)); + + // The job must be untouched in its own container + assertEquals("Job status must be unchanged after a cross-container retry", + PipelineJob.TaskStatus.error.toString(), getStatusFile((int) rowId).getStatus()); + + // Positive control (a real successful retry needs a serialized job) is covered by existing pipeline retry tests. + } + + @Test + public void testShowDataContainerScoping() throws Exception + { + User admin = getAdmin(); + Container folderA = createContainer("A"); + Container folderB = createContainer("B"); + User readerA = createUserInRole(folderA, ReaderRole.class); + + long rowId = insertStatusFile(folderB, PipelineJob.TaskStatus.complete.toString()).getRowId(); + + // Addressing B's job through folder A must 404 rather than redirect to / expose it + ActionURL foreignUrl = new ActionURL(ShowDataAction.class, folderA).addParameter("rowId", String.valueOf(rowId)); + assertStatus(HttpServletResponse.SC_NOT_FOUND, get(foreignUrl, readerA)); + // A site admin who CAN read folder B gets a redirect + assertStatus(HttpServletResponse.SC_FOUND, get(foreignUrl, admin)); + + // Positive control: through its own container the action redirects (302) rather than 404 + ActionURL ownUrl = new ActionURL(ShowDataAction.class, folderB).addParameter("rowId", String.valueOf(rowId)); + assertStatus(HttpServletResponse.SC_FOUND, get(ownUrl, admin)); + } + + @Test + public void testCompleteStatusContainerScoping() throws Exception + { + User admin = getAdmin(); + Container folderA = createContainer("A"); + Container folderB = createContainer("B"); + User readerA = createUserInRole(folderA, ReaderRole.class); + + // A job in folder B with no serialized job store, so completeStatus() takes its fallback (bean update) path + int rowId = insertStatusFile(folderB, PipelineJob.TaskStatus.error.toString()).getRowId(); + + // A caller with no rights in folder B must not be able to mark B's job complete via the fallback path + try + { + completeStatus(readerA, List.of(rowId)); + fail("Expected UnauthorizedException marking a job complete in a container the caller can't update"); + } + catch (UnauthorizedException ignored) {} + assertEquals("Status must be unchanged after an unauthorized complete", + PipelineJob.TaskStatus.error.toString(), getStatusFile(rowId).getStatus()); + + // Positive control: a site admin (Update in B) can complete it through the same fallback path + completeStatus(admin, List.of(rowId)); + assertEquals("Admin should be able to mark the job complete", + PipelineJob.TaskStatus.complete.toString(), getStatusFile(rowId).getStatus()); + } + + @Test + public void testDetailsContainerScoping() throws Exception + { + // DetailsAction is the HTML job-details page (distinct from the StatusDetailsAction API). It resolves the + // job by global rowId; without the guard a caller could view another folder's job details through their own + // folder. The guard 404s when the caller cannot read the job's container, and otherwise redirects to it. + User admin = getAdmin(); + Container folderA = createContainer("A"); + Container folderB = createContainer("B"); + User readerA = createUserInRole(folderA, ReaderRole.class); + + long rowId = insertStatusFile(folderB, PipelineJob.TaskStatus.complete.toString()).getRowId(); + + ActionURL foreignUrl = new ActionURL(DetailsAction.class, folderA).addParameter("rowId", String.valueOf(rowId)); + // A caller who can read folder A but NOT folder B must get 404 -- the page must not reveal B's job exists. + assertStatus(HttpServletResponse.SC_NOT_FOUND, get(foreignUrl, readerA)); + // A site admin who CAN read folder B is redirected (302) to B rather than rendering B's job through A. + assertStatus(HttpServletResponse.SC_FOUND, get(foreignUrl, admin)); + + // Positive control: through its own container the details page renders (200), proving the guard rejects + // only the cross-container case rather than every request. + ActionURL ownUrl = new ActionURL(DetailsAction.class, folderB).addParameter("rowId", String.valueOf(rowId)); + assertStatus(HttpServletResponse.SC_OK, get(ownUrl, admin)); + } + + // Insert a bare status file in the given container. FilePath is a required column; point it at a non-existent + // log so nothing tries to read it, and so getJobStore().getJob() returns null (exercising the bean-only paths). + private PipelineStatusFileImpl insertStatusFile(Container c, String status) + { + User admin = getAdmin(); + PipelineStatusFileImpl sf = new PipelineStatusFileImpl(); + sf.beforeInsert(admin, c.getId()); + sf.setStatus(status); + // uq_statusfiles_filepath is a global unique constraint, so the path must be unique per run -- a GUID keeps + // a leftover row from an interrupted prior run from colliding with this insert. + sf.setFilePath(FileUtil.appendName(FileUtil.getTempDirectory(), "pipeline-scoping-test-" + c.getRowId() + "-" + status + "-" + GUID.makeGUID() + ".log").getAbsolutePath()); + return Table.insert(admin, PipelineSchema.getInstance().getTableInfoStatusFiles(), sf); + } } } diff --git a/query/src/org/labkey/query/QueryModule.java b/query/src/org/labkey/query/QueryModule.java index ada49cf15ba..f0e028f724f 100644 --- a/query/src/org/labkey/query/QueryModule.java +++ b/query/src/org/labkey/query/QueryModule.java @@ -353,6 +353,7 @@ public Set getIntegrationTests() return Set.of( ModuleReportCache.TestCase.class, OlapController.TestCase.class, + OlapController.ContainerScopingTestCase.class, QueryController.TestCase.class, QueryController.SaveRowsTestCase.class, QueryServiceImpl.TestCase.class, diff --git a/query/src/org/labkey/query/controllers/OlapController.java b/query/src/org/labkey/query/controllers/OlapController.java index eac4835c06f..382c3557716 100644 --- a/query/src/org/labkey/query/controllers/OlapController.java +++ b/query/src/org/labkey/query/controllers/OlapController.java @@ -23,6 +23,7 @@ import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.junit.Test; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.json.JSONObject; @@ -56,8 +57,11 @@ import org.labkey.api.data.RuntimeSQLException; import org.labkey.api.data.SQLFragment; import org.labkey.api.data.SqlSelector; +import org.labkey.api.data.Table; import org.labkey.api.data.queryprofiler.QueryProfiler; import org.labkey.api.query.DefaultSchema; +import org.labkey.api.data.SimpleFilter; +import org.labkey.api.data.TableSelector; import org.labkey.api.query.FieldKey; import org.labkey.api.query.QueryParseException; import org.labkey.api.query.QueryParseExceptionUnresolvedField; @@ -67,8 +71,10 @@ 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.AbstractContainerScopingTest; import org.labkey.api.security.permissions.AdminPermission; import org.labkey.api.security.permissions.ReadPermission; +import org.labkey.api.security.roles.FolderAdminRole; import org.labkey.api.study.DataspaceContainerFilter; import org.labkey.api.util.Compress; import org.labkey.api.util.ConfigurationException; @@ -463,6 +469,21 @@ protected DataView createView(CustomOlapDescriptorForm form, Errors errors) return new UpdateView(form, (BindException)errors); } + @Override + public void validateCommand(CustomOlapDescriptorForm form, Errors errors) + { + // Confirm definition lives in this container before touching it + Object pk = form.getPkVal(); + if (pk == null) + throw new NotFoundException("Custom olap definition not found"); + SimpleFilter filter = SimpleFilter.createContainerFilter(getContainer()); + filter.addCondition(FieldKey.fromParts("RowId"), pk); + if (!new TableSelector(QueryManager.get().getTableInfoOlapDef(), filter, null).exists()) + throw new NotFoundException("Custom olap definition not found in this folder"); + + super.validateCommand(form, errors); + } + @Override protected boolean doAction(CustomOlapDescriptorForm form, Errors errors) throws SQLException { @@ -1595,4 +1616,50 @@ controller.new ListAppsAction() ); } } + + public static class ContainerScopingTestCase extends AbstractContainerScopingTest + { + @Test + public void testEditDefinitionContainerScoping() throws Exception + { + User admin = getAdmin(); + Container folderA = createContainer("A"); + Container folderB = createContainer("B"); + + // A custom OLAP definition that lives in folder B. Its stored content need not be valid Rolap: the update + // action validates only the *submitted* form, and the container guard runs before that validation. + OlapDef def = new OlapDef(); + def.beforeInsert(admin, folderB.getId()); + def.setName("scoping-test-cube"); + def.setModule("query"); + def.setDefinition(""); + def = Table.insert(admin, QueryManager.get().getTableInfoOlapDef(), def); + int rowId = def.getRowId(); + + // A caller who is folder admin in A (so the @RequiresPermission(AdminPermission.class) check passes) but + // has no rights in folder B. + User adminA = createUserInRole(folderA, FolderAdminRole.class); + + // Editing B's definition through folder A must 404 rather than overwrite/re-home it. Without the + // validateCommand guard the unscoped doUpdate() would edit B's row and rewrite its container to A. + ActionURL foreignUrl = new ActionURL(EditDefinitionAction.class, folderA).addParameter("RowId", String.valueOf(rowId)); + assertStatus(HttpServletResponse.SC_NOT_FOUND, post(foreignUrl, adminA)); + // A site admin, who CAN administer folder B, still gets 404 through folder A (no cross-container write). + assertStatus(HttpServletResponse.SC_NOT_FOUND, post(foreignUrl, admin)); + + // The definition must be untouched in its own container. + OlapDef after = new TableSelector(QueryManager.get().getTableInfoOlapDef()).getObject(rowId, OlapDef.class); + assertNotNull("Definition must still exist", after); + assertEquals("Container must be unchanged after a cross-container edit", folderB.getId(), after.getContainerId()); + + // Positive control: a same-container request passes the container guard and proceeds to form validation. The + // submitted definition is intentionally invalid Rolap, so the action redisplays the form (200) instead of + // 404 -- proving the guard rejects only the cross-container case, not legitimate same-container edits. + ActionURL ownUrl = new ActionURL(EditDefinitionAction.class, folderB) + .addParameter("RowId", String.valueOf(rowId)) + .addParameter("Module", "query") + .addParameter("Definition", "not-valid-rolap-xml"); + assertStatus(HttpServletResponse.SC_OK, post(ownUrl, admin)); + } + } } diff --git a/specimen/src/org/labkey/specimen/actions/SpecimenApiController.java b/specimen/src/org/labkey/specimen/actions/SpecimenApiController.java index e9a7dd6dd86..2621a19e267 100644 --- a/specimen/src/org/labkey/specimen/actions/SpecimenApiController.java +++ b/specimen/src/org/labkey/specimen/actions/SpecimenApiController.java @@ -286,6 +286,7 @@ public class GetRequestAction extends ReadOnlyApiAction @Override public ApiResponse execute(RequestIdForm requestIdForm, BindException errors) { + // OK for anyone with read access to see any request in this container, even if they didn't create it SpecimenRequest request = getRequest(getUser(), getContainer(), requestIdForm.getRequestId(), false, false); final Map response = new HashMap<>(); response.put("request", request != null ? getRequestResponse(getViewContext(), request) : null); diff --git a/specimen/src/org/labkey/specimen/actions/SpecimenController.java b/specimen/src/org/labkey/specimen/actions/SpecimenController.java index 9d48a30e7c7..059d63a9fcf 100644 --- a/specimen/src/org/labkey/specimen/actions/SpecimenController.java +++ b/specimen/src/org/labkey/specimen/actions/SpecimenController.java @@ -63,6 +63,7 @@ import org.labkey.api.gwt.client.AuditBehaviorType; import org.labkey.api.module.FolderType; import org.labkey.api.module.Module; +import org.labkey.api.module.ModuleLoader; import org.labkey.api.pipeline.PipeRoot; import org.labkey.api.pipeline.PipelineService; import org.labkey.api.pipeline.PipelineStatusUrls; @@ -87,6 +88,7 @@ import org.labkey.api.security.permissions.AdminPermission; import org.labkey.api.security.permissions.ReadPermission; import org.labkey.api.security.permissions.UpdatePermission; +import org.labkey.api.security.roles.ReaderRole; import org.labkey.api.specimen.SpecimenQuerySchema; import org.labkey.api.specimen.SpecimenSchema; import org.labkey.api.specimen.Vial; @@ -151,8 +153,10 @@ import org.labkey.specimen.RequestedSpecimens; import org.labkey.specimen.SpecimenManager; import org.labkey.specimen.SpecimenRequestException; +import org.labkey.specimen.SpecimenModule; import org.labkey.specimen.SpecimenRequestManager; import org.labkey.specimen.SpecimenRequestStatus; +import org.labkey.specimen.security.roles.SpecimenRequesterRole; import org.labkey.specimen.importer.RequestabilityManager; import org.labkey.specimen.importer.SimpleSpecimenImporter; import org.labkey.specimen.model.ExtendedSpecimenRequestView; @@ -3801,6 +3805,8 @@ public boolean handlePost(final ManageRequestStatusForm form, BindException erro if (_specimenRequest == null) throw new NotFoundException(); + requiresEditRequestPermissions(_specimenRequest); + boolean statusChanged = form.getStatus() != _specimenRequest.getStatusId(); boolean detailsChanged = !nullSafeEqual(form.getRequestDescription(), _specimenRequest.getComments()); @@ -5849,5 +5855,77 @@ public void testDeleteRequirementActionRejectsForeignRequirement() throws Except post(ownUrl, getAdmin()); assertNull("Same-container delete should remove the requirement", provider.getRequirement(folderA, requirementId)); } + + @Test + public void testDeleteActorActionRejectsForeignActor() throws Exception + { + Container folderA = createContainer("A"); + Container folderB = createContainer("B"); + SpecimenRequestRequirementProvider provider = SpecimenRequestRequirementProvider.get(); + + // An actor that lives in folder A + SpecimenRequestActor actor = createActor(folderA, "Delete actor scoping actor"); + int actorId = actor.getRowId(); + + // Deleting through folder B, where the actor does not live, must be a graceful no-op rather than a 500: + // the fix makes getActor container-scoped, so handlePost sees null and skips actor.delete() instead of + // NPEing. The action still redirects (302) and the actor must survive in its own folder. + ActionURL foreignUrl = new ActionURL(DeleteActorAction.class, folderB) + .addParameter("id", String.valueOf(actorId)); + assertStatus(HttpServletResponse.SC_FOUND, post(foreignUrl, getAdmin())); + assertNotNull("Cross-container delete must not remove the actor", provider.getActor(folderA, actorId)); + + // Positive control: deleting through the actor's own folder removes it, proving the guard rejects only the + // cross-container case rather than every delete + ActionURL ownUrl = new ActionURL(DeleteActorAction.class, folderA) + .addParameter("id", String.valueOf(actorId)); + assertStatus(HttpServletResponse.SC_FOUND, post(ownUrl, getAdmin())); + assertNull("Same-container delete should remove the actor", provider.getActor(folderA, actorId)); + } + + @Test + public void testManageRequestStatusRequiresEditPermission() throws Exception + { + // ManageRequestStatusAction is annotated @RequiresPermission(RequestSpecimensPermission.class), which only + // proves the caller may make requests. However, a requester shouldn't be able to change ANOTHER user's request + // + // SpecimenRequesterRole (RequestSpecimensPermission only, no ManageRequestsPermission) is applicable only + // in a study folder that has the Specimen module, so stand up a minimal study. + Container folder = createContainer("ManageStatus"); + Set modules = new HashSet<>(folder.getActiveModules()); + modules.add(ModuleLoader.getInstance().getModule("study")); + modules.add(ModuleLoader.getInstance().getModule(SpecimenModule.NAME)); + folder.setActiveModules(modules, getAdmin()); + StudyService.get().createStudy(folder, getAdmin(), "Specimen scoping study", TimepointType.VISIT, true); + + // A request OWNED BY THE ADMIN (not the attacker), in a freshly created status + SpecimenRequestStatus status = new SpecimenRequestStatus(); + status.setContainer(folder); + status.setLabel("Manage status scoping status"); + status.setSortOrder(1); // non-null and >= 0 so the bean isn't treated as a system status + status = Table.insert(getAdmin(), SpecimenSchema.get().getTableInfoSampleRequestStatus(), status); + + SpecimenRequest request = new SpecimenRequest(); + request.setContainer(folder); + request.setStatusId(status.getRowId()); + request = SpecimenRequestManager.get().createRequest(getAdmin(), request, false); + int requestId = request.getRowId(); + + // A plain requester: holds RequestSpecimensPermission (so the action's annotation passes) but is neither a + // coordinator nor the request's owner, so hasEditRequestPermissions() is false. + User requester = createUserInRole(folder, SpecimenRequesterRole.class); + + // Mutating another user's request must be rejected at the per-request guard (403) rather than applied. The + // request id and a no-op status keep the form valid, so without the fix the action proceeds and does NOT 403. + ActionURL url = new ActionURL(ManageRequestStatusAction.class, folder) + .addParameter("id", String.valueOf(requestId)) + .addParameter("status", String.valueOf(status.getRowId())); + assertStatus(HttpServletResponse.SC_FORBIDDEN, post(url, requester)); + + // Positive control: an admin holds ManageRequestsPermission, so the same request passes the guard rather + // than being blocked at 403 -- proving the guard rejects only the unprivileged requester, not every caller. + assertNotEquals("An admin must pass the edit-request guard, not be blocked at 403", + HttpServletResponse.SC_FORBIDDEN, post(url, getAdmin()).getStatus()); + } } } diff --git a/study/src/org/labkey/study/StudyModule.java b/study/src/org/labkey/study/StudyModule.java index 322655a8487..44a8ddb7a11 100644 --- a/study/src/org/labkey/study/StudyModule.java +++ b/study/src/org/labkey/study/StudyModule.java @@ -125,6 +125,7 @@ import org.labkey.study.audit.ParticipantGroupAuditProvider; import org.labkey.study.audit.StudyAuditProvider; import org.labkey.study.controllers.CohortController; +import org.labkey.study.controllers.CreateChildStudyAction; import org.labkey.study.controllers.DatasetController; import org.labkey.study.controllers.ParticipantGroupController; import org.labkey.study.controllers.SharedStudyController; @@ -713,6 +714,7 @@ public Set getIntegrationTests() DatasetDefinition.TestCleanupOrphanedDatasetDomains.class, DataStatesTest.class, ParticipantGroupManager.ParticipantGroupTestCase.class, + ParticipantGroupManager.ContainerScopingTestCase.class, StudyImpl.ProtocolDocumentTestCase.class, StudyManager.StudySnapshotTestCase.class, StudyManager.VisitCreationTestCase.class, @@ -720,7 +722,9 @@ public Set getIntegrationTests() VisitImpl.TestCase.class, DatasetUpdateService.TestCase.class, DatasetLsidImportHelper.TestCase.class, - org.labkey.study.controllers.CreateChildStudyAction.ContainerScopingTestCase.class); + CreateChildStudyAction.ContainerScopingTestCase.class, + StudyController.ContainerScopingTestCase.class, + ReportsController.ContainerScopingTestCase.class); } @Override diff --git a/study/src/org/labkey/study/controllers/StudyController.java b/study/src/org/labkey/study/controllers/StudyController.java index 6ca457fdf43..fe3cc9c94ae 100644 --- a/study/src/org/labkey/study/controllers/StudyController.java +++ b/study/src/org/labkey/study/controllers/StudyController.java @@ -163,7 +163,9 @@ import org.labkey.api.security.RequiresNoPermission; import org.labkey.api.security.RequiresPermission; import org.labkey.api.security.SecurityManager; +import org.junit.Test; import org.labkey.api.security.User; +import org.labkey.api.security.permissions.AbstractContainerScopingTest; import org.labkey.api.security.permissions.AdminPermission; import org.labkey.api.security.permissions.BrowserDeveloperPermission; import org.labkey.api.security.permissions.DeletePermission; @@ -1588,7 +1590,8 @@ private void deleteFromParticipantGroupMapTable(TableInfo ti, String participant { try { - SQLFragment sql = new SQLFragment("DELETE FROM study.participantgroupmap WHERE participantid = ?", participantId); + // Scope the raw DELETE to the request container (DeletePermission is only proven there) + SQLFragment sql = new SQLFragment("DELETE FROM study.participantgroupmap WHERE participantid = ? AND container = ?", participantId, getContainer().getId()); new SqlExecutor(ti.getSchema()).execute(sql); } catch (Exception e) @@ -7899,4 +7902,54 @@ public void addNavTrail(NavTree root) root.addChild(_study != null ? "Overview: " + _study.getLabel() : "No Study"); } } + + public static class ContainerScopingTestCase extends AbstractContainerScopingTest + { + @Test + public void testDeleteParticipantContainerScoping() throws Exception + { + // DeleteParticipantAction removes a participant's dataset rows AND its participant-group memberships. The + // group-map DELETE was keyed only by participantId, so deleting a participant through folder A also wiped + // that participant's group memberships in OTHER folders. The fix scopes the DELETE to the request container. + Container folderA = createContainer("A"); + Container folderB = createContainer("B"); + StudyService.get().createStudy(folderA, getAdmin(), "Study A", TimepointType.VISIT, true); + StudyService.get().createStudy(folderB, getAdmin(), "Study B", TimepointType.VISIT, true); + + // P1 must be a known participant in folder B before it can be added to a group there + insertParticipant(folderB, "P1"); + + // Put P1 into a participant group in folder B (creates a study.participantgroupmap row scoped to B) + ParticipantCategoryImpl cat = new ParticipantCategoryImpl(); + cat.setContainer(folderB.getId()); + cat.setLabel("scoping-category"); + cat.setType("list"); + ParticipantGroupManager.getInstance().setParticipantCategory(folderB, getAdmin(), cat, new String[]{"P1"}, null, "scoping"); + assertEquals("Setup: P1 must be in a group in folder B", 1, groupMapCount(folderB, "P1")); + + // Delete P1 through folder A. Folder A has its own study, so the action runs; without the fix its group-map + // DELETE (keyed only by participantId) would also remove P1's membership in folder B. + ActionURL url = new ActionURL(DeleteParticipantAction.class, folderA).addParameter("participantId", "P1"); + post(url, getAdmin()); + + // P1's group membership in folder B must survive a delete issued through folder A. + assertEquals("P1's group membership in folder B must survive a cross-container participant delete", + 1, groupMapCount(folderB, "P1")); + } + + private void insertParticipant(Container c, String ptid) + { + Map row = new HashMap<>(); + row.put("Container", c.getId()); + row.put("ParticipantId", ptid); + Table.insert(getAdmin(), StudySchema.getInstance().getTableInfoParticipant(), row); + } + + private long groupMapCount(Container c, String ptid) + { + SimpleFilter f = new SimpleFilter(FieldKey.fromParts("ParticipantId"), ptid); + f.addCondition(FieldKey.fromParts("Container"), c.getId()); + return new TableSelector(StudySchema.getInstance().getTableInfoParticipantGroupMap(), f, null).getRowCount(); + } + } } diff --git a/study/src/org/labkey/study/controllers/reports/ReportsController.java b/study/src/org/labkey/study/controllers/reports/ReportsController.java index f2b1b06dae4..65f56b36497 100644 --- a/study/src/org/labkey/study/controllers/reports/ReportsController.java +++ b/study/src/org/labkey/study/controllers/reports/ReportsController.java @@ -43,6 +43,12 @@ import org.labkey.api.query.ValidationError; import org.labkey.api.reports.Report; import org.labkey.api.reports.ReportService; +import org.labkey.api.reports.report.QueryReport; +import org.labkey.api.security.permissions.AbstractContainerScopingTest; +import org.labkey.api.security.roles.ReaderRole; +import org.labkey.api.writer.DefaultContainerUser; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.Test; import org.labkey.api.reports.report.ReportDescriptor; import org.labkey.api.reports.report.ReportIdentifier; import org.labkey.api.reports.report.ReportUrls; @@ -73,6 +79,7 @@ import org.labkey.api.view.JspView; import org.labkey.api.view.NavTree; import org.labkey.api.view.NotFoundException; +import org.labkey.api.view.UnauthorizedException; import org.labkey.api.view.VBox; import org.labkey.api.view.ViewContext; import org.labkey.api.view.ViewForm; @@ -317,6 +324,11 @@ public ModelAndView getView(ShowReportForm form, BindException errors) throws Ex throw new NotFoundException(message); } + // getReport(container, rowId) is container-scoped but does no owner/SecurityPolicy filtering. Enforce the + // per-report read check + if (!ReportManager.get().canReadReport(getUser(), getContainer(), report)) + throw new UnauthorizedException("You do not have permission to view this report."); + return report.renderReport(getViewContext()); } @@ -1291,6 +1303,35 @@ public void bindJson(JSONObject json) } } + public static class ContainerScopingTestCase extends AbstractContainerScopingTest + { + @Test + public void testShowReportRequiresReadPermission() throws Exception + { + // ShowReportAction resolves a report by global rowId with getReport(container, rowId) -- container-scoped, + // but with no owner/policy check. Without the fix a plain container Reader could render another user's + // PRIVATE report by guessing its rowId. The fix adds the per-report canReadReport() check. + Container folder = createContainer("A"); + + // A PRIVATE report: owned by the admin (descriptor owner set), so only the owner / a site admin may read it. + Report report = ReportService.get().createReportInstance(QueryReport.TYPE); + report.getDescriptor().setReportName("scoping-private-report"); + report.getDescriptor().setOwner(getAdmin().getUserId()); + int reportId = ReportService.get().saveReportEx(new DefaultContainerUser(folder, getAdmin()), "scoping-report-key", report).getRowId(); + + ActionURL url = new ActionURL(ShowReportAction.class, folder).addParameter("reportId", String.valueOf(reportId)); + + // A Reader who does not own the report must be rejected (403) rather than shown another user's private report. + User reader = createUserInRole(folder, ReaderRole.class); + assertStatus(HttpServletResponse.SC_FORBIDDEN, get(url, reader)); + + // Positive control: the report's owner passes the per-report read check rather than being blocked at 403, + // proving the guard rejects only the unauthorized reader. + assertNotEquals("The report owner must pass the read check, not be blocked at 403", + HttpServletResponse.SC_FORBIDDEN, get(url, getAdmin()).getStatus()); + } + } + public static class TestCase extends AbstractActionPermissionTest { @Override diff --git a/study/src/org/labkey/study/model/ParticipantGroupManager.java b/study/src/org/labkey/study/model/ParticipantGroupManager.java index 945b07a8029..7a148b7210a 100644 --- a/study/src/org/labkey/study/model/ParticipantGroupManager.java +++ b/study/src/org/labkey/study/model/ParticipantGroupManager.java @@ -41,6 +41,8 @@ import org.labkey.api.query.ValidationError; import org.labkey.api.query.ValidationException; import org.labkey.api.security.User; +import org.labkey.api.security.permissions.AbstractContainerScopingTest; +import org.labkey.api.security.roles.ReaderRole; import org.labkey.api.security.permissions.AdminPermission; import org.labkey.api.security.permissions.ReadPermission; import org.labkey.api.settings.ResourceURL; @@ -48,6 +50,7 @@ import org.labkey.api.study.ParticipantCategory; import org.labkey.api.study.Study; import org.labkey.api.study.StudyService; +import org.labkey.api.study.TimepointType; import org.labkey.api.study.model.ParticipantGroup; import org.labkey.api.study.permissions.SharedParticipantGroupPermission; import org.labkey.api.util.MemTracker; @@ -578,11 +581,10 @@ private ParticipantGroup _setParticipantGroup(Container c, User user, Participan if (cat == null) throw new ValidationException("The specified category was not found."); - if (cat.isShared()) - { - if (!c.hasPermission(user, SharedParticipantGroupPermission.class) && !c.hasPermission(user, AdminPermission.class)) - throw new ValidationException("You must be in the Editor role or an Admin to assign a group to a shared participant category"); - } + // canEdit enforces the SharedParticipantGroupPermission/Admin check for shared categories AND the + // owner check for private categories + if (!cat.canEdit(c, user)) + throw new ValidationException("You do not have permission to modify groups in this participant category"); } ParticipantGroup ret; @@ -1176,4 +1178,56 @@ public void test() p.getParticipantGroups(null, null, def); } } + + public static class ContainerScopingTestCase extends AbstractContainerScopingTest + { + @Test + public void testSetParticipantGroupRequiresOwnership() throws Exception + { + // setParticipantGroup() saves a group keyed by a global rowId. For a PRIVATE category, canEdit() allows only + // the category's creator -- but the manager previously enforced only the shared-category case, so a Read + // user could overwrite another user's private group via a guessable rowId. The fix gates _setParticipantGroup + // on canEdit() for the private case too. + Container folder = createContainer("A"); + StudyService.get().createStudy(folder, getAdmin(), "Study", TimepointType.VISIT, true); + insertParticipant(folder, "P1"); + + ParticipantGroupManager mgr = ParticipantGroupManager.getInstance(); + + // A PRIVATE participant category + group owned by the admin (ownerId != OWNER_SHARED makes it private; the + // admin is its creator, so only the admin may edit it). + ParticipantCategoryImpl cat = new ParticipantCategoryImpl(); + cat.setContainer(folder.getId()); + cat.setLabel("private-category"); + cat.setType("list"); + cat.setOwnerId(getAdmin().getUserId()); + cat = mgr.setParticipantCategory(folder, getAdmin(), cat, new String[]{"P1"}, null, "private"); + ParticipantGroup group = mgr.getParticipantGroups(folder, getAdmin(), cat).get(0); + + // A different user with only Read access (not the owner, not an admin) + User attacker = createUserInRole(folder, ReaderRole.class); + + // Saving (overwriting) the admin's private group as the attacker must be rejected. + try + { + mgr.setParticipantGroup(folder, attacker, group); + fail("A non-owner must not be able to modify another user's private participant group"); + } + catch (ValidationException expected) + { + } + + // Positive control: the owner (admin) can still save their own private group -- the guard rejects only the + // non-owner, not every caller. + mgr.setParticipantGroup(folder, getAdmin(), group); + } + + private void insertParticipant(Container c, String ptid) + { + Map row = new HashMap<>(); + row.put("Container", c.getId()); + row.put("ParticipantId", ptid); + Table.insert(getAdmin(), StudySchema.getInstance().getTableInfoParticipant(), row); + } + } } diff --git a/studydesign/src/org/labkey/studydesign/StudyDesignController.java b/studydesign/src/org/labkey/studydesign/StudyDesignController.java index 12b5224afff..18122fcbe5e 100644 --- a/studydesign/src/org/labkey/studydesign/StudyDesignController.java +++ b/studydesign/src/org/labkey/studydesign/StudyDesignController.java @@ -17,6 +17,7 @@ import org.apache.commons.lang3.StringUtils; import org.jetbrains.annotations.Nullable; +import org.junit.Test; import org.json.JSONArray; import org.json.JSONObject; import org.labkey.api.action.ApiJsonForm; @@ -31,11 +32,13 @@ import org.labkey.api.data.DbScope; import org.labkey.api.data.SimpleFilter; import org.labkey.api.data.Table; +import org.labkey.api.data.TableSelector; import org.labkey.api.query.FieldKey; import org.labkey.api.query.ValidationException; import org.labkey.api.security.ActionNames; import org.labkey.api.security.RequiresPermission; import org.labkey.api.security.User; +import org.labkey.api.security.permissions.AbstractContainerScopingTest; import org.labkey.api.security.permissions.ReadPermission; import org.labkey.api.security.permissions.UpdatePermission; import org.labkey.api.study.Cohort; @@ -51,6 +54,7 @@ import org.labkey.api.util.PageFlowUtil; import org.labkey.api.view.ActionURL; import org.labkey.api.view.HttpView; +import org.labkey.api.view.NotFoundException; import org.labkey.api.view.JspView; import org.labkey.api.view.NavTree; import org.labkey.studydesign.model.AssaySpecimenConfigImpl; @@ -946,4 +950,52 @@ private void updateAssayPlan(User user, Study study, String assayPlan) StudyService.get().updateAssayPlan(user, study, assayPlan); } } + + /** + * Verifies the cross-container guard on the DoseAndRoute save path reached by UpdateStudyProductsAction. That + * action's DoseAndRoute branch alone writes through the raw storage table keyed by a client-supplied RowId, so the + * guard lives in TreatmentManager.saveStudyProductDoseAndRoute and is exercised directly here (building a full study + * + product + JSON product payload to drive the action end-to-end would add a large fixture for the same assertion). + */ + public static class ContainerScopingTestCase extends AbstractContainerScopingTest + { + @Test + public void testSaveDoseAndRouteContainerScoping() + { + User admin = getAdmin(); + Container folderA = createContainer("A"); + Container folderB = createContainer("B"); + + // A DoseAndRoute row that lives in folder B + DoseAndRoute existing = Table.insert(admin, StudyDesignSchema.getInstance().getTableInfoDoseAndRoute(), + new DoseAndRoute("1 mg", "IV", 1, folderB)); + int rowId = existing.getRowId(); + + // Deny: an update keyed by B's RowId while operating in folder A must be rejected, not silently + // overwrite/re-home the foreign row. This is the path an Editor in folder A reaches via + // UpdateStudyProductsAction by submitting a products[].DoseAndRoute[].RowId owned by folder B. + DoseAndRoute attack = new DoseAndRoute("HACKED", "HACKED", 1, folderA); + attack.setRowId(rowId); + try + { + TreatmentManager.getInstance().saveStudyProductDoseAndRoute(folderA, admin, attack); + fail("Expected NotFoundException updating a DoseAndRoute owned by another container"); + } + catch (NotFoundException ignored) {} + + // The row in folder B must be untouched + DoseAndRoute reloaded = new TableSelector(StudyDesignSchema.getInstance().getTableInfoDoseAndRoute()) + .getObject(rowId, DoseAndRoute.class); + assertNotNull("DoseAndRoute must still exist", reloaded); + assertEquals("Dose must be unchanged after a cross-container update", "1 mg", reloaded.getDose()); + + // Positive control: updating through the row's own container succeeds and persists the change + DoseAndRoute ok = new DoseAndRoute("2 mg", "IM", 1, folderB); + ok.setRowId(rowId); + TreatmentManager.getInstance().saveStudyProductDoseAndRoute(folderB, admin, ok); + DoseAndRoute afterOk = new TableSelector(StudyDesignSchema.getInstance().getTableInfoDoseAndRoute()) + .getObject(rowId, DoseAndRoute.class); + assertEquals("Dose should be updated by a same-container request", "2 mg", afterOk.getDose()); + } + } } \ No newline at end of file diff --git a/studydesign/src/org/labkey/studydesign/StudyDesignModule.java b/studydesign/src/org/labkey/studydesign/StudyDesignModule.java index eff8232833b..1de6480178a 100644 --- a/studydesign/src/org/labkey/studydesign/StudyDesignModule.java +++ b/studydesign/src/org/labkey/studydesign/StudyDesignModule.java @@ -77,7 +77,8 @@ public Set getIntegrationTests() { return Set.of( TreatmentManager.TreatmentDataTestCase.class, - TreatmentManager.AssayScheduleTestCase.class + TreatmentManager.AssayScheduleTestCase.class, + StudyDesignController.ContainerScopingTestCase.class ); } } \ No newline at end of file diff --git a/studydesign/src/org/labkey/studydesign/model/TreatmentManager.java b/studydesign/src/org/labkey/studydesign/model/TreatmentManager.java index 1c441aef779..71ec931600b 100644 --- a/studydesign/src/org/labkey/studydesign/model/TreatmentManager.java +++ b/studydesign/src/org/labkey/studydesign/model/TreatmentManager.java @@ -33,6 +33,7 @@ import org.labkey.api.module.ModuleLoader; import org.labkey.api.query.BatchValidationException; import org.labkey.api.query.FieldKey; +import org.labkey.api.view.NotFoundException; import org.labkey.api.query.FilteredTable; import org.labkey.api.query.QueryService; import org.labkey.api.query.QueryUpdateService; @@ -54,7 +55,6 @@ 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; @@ -366,16 +366,14 @@ 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()); - } + // 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) diff --git a/survey/src/org/labkey/survey/SurveyManager.java b/survey/src/org/labkey/survey/SurveyManager.java index 66be29ef8b0..67c9338f582 100644 --- a/survey/src/org/labkey/survey/SurveyManager.java +++ b/survey/src/org/labkey/survey/SurveyManager.java @@ -63,6 +63,8 @@ import org.labkey.api.resource.Resource; import org.labkey.api.security.User; import org.labkey.api.security.permissions.AbstractContainerScopingTest; +import org.labkey.api.security.permissions.ReadPermission; +import org.labkey.api.security.roles.ReaderRole; import org.labkey.api.survey.model.Survey; import org.labkey.api.survey.model.SurveyDesign; import org.labkey.api.survey.model.SurveyListener; @@ -266,13 +268,16 @@ public Survey saveSurvey(Container container, User user, Survey survey) } } + /** Checks that the user has read permission to the container that owns the design, but we accept cross-container references */ @Nullable public SurveyDesign getSurveyDesign(Container container, User user, int surveyId) { - // Scope by container SimpleFilter filter = new SimpleFilter(FieldKey.fromParts("rowId"), surveyId); - filter.addCondition(FieldKey.fromParts("Container"), container); - return new TableSelector(SurveySchema.getInstance().getSurveyDesignsTable(), filter, null).getObject(SurveyDesign.class); + SurveyDesign result = new TableSelector(SurveySchema.getInstance().getSurveyDesignsTable(), filter, null).getObject(SurveyDesign.class); + if (result == null) + return null; + Container actualContainer = ContainerManager.getForId(result.getContainerId()); + return actualContainer == null || !actualContainer.hasPermission(user, ReadPermission.class) ? null : result; } /** @@ -808,7 +813,7 @@ public void setUp() } @Test - public void testSurveyDesignContainerScoping() + public void testSurveyDesignContainerScoping() throws Exception { SurveyManager sm = SurveyManager.get(); @@ -817,9 +822,21 @@ public void testSurveyDesignContainerScoping() design = sm.saveSurveyDesign(_projectA, _user, design); int designId = design.getRowId(); - // Same-container lookup succeeds; cross-container lookup must return null - assertNotNull("Design should be visible from its own container", sm.getSurveyDesign(_projectA, _user, designId)); - assertNull("Design must NOT be visible from another container", sm.getSurveyDesign(_projectB, _user, designId)); + // 1. Same container: a user with read access in the design's container sees it. + User readerA = createUserInRole(_projectA, ReaderRole.class); + assertNotNull("Design should be visible from its own container to a user with read access", + sm.getSurveyDesign(_projectA, readerA, designId)); + + // 2. Different container, caller can read the design's container: tolerated, design is returned. + User readerAB = createUserInRole(_projectA, ReaderRole.class); + grantRole(readerAB, _projectB, ReaderRole.class); + assertNotNull("Design should be visible from another container when the caller can read the design's container", + sm.getSurveyDesign(_projectB, readerAB, designId)); + + // 3. Different container, caller cannot read the design's container: must return null. + User readerB = createUserInRole(_projectB, ReaderRole.class); + assertNull("Design must NOT be visible to a caller without read access to the design's container", + sm.getSurveyDesign(_projectB, readerB, designId)); // A delete issued from the wrong container must not remove the design sm.deleteSurveyDesign(_projectB, _user, designId, true); diff --git a/wiki/src/org/labkey/wiki/WikiController.java b/wiki/src/org/labkey/wiki/WikiController.java index 0525ecec9d7..3fed40cf90f 100644 --- a/wiki/src/org/labkey/wiki/WikiController.java +++ b/wiki/src/org/labkey/wiki/WikiController.java @@ -62,7 +62,9 @@ import org.labkey.api.security.permissions.AbstractContainerScopingTest; import org.labkey.api.security.permissions.AdminPermission; import org.labkey.api.security.permissions.ReadPermission; +import org.labkey.api.security.roles.EditorRole; import org.labkey.api.security.roles.FolderAdminRole; +import org.labkey.api.security.roles.ReaderRole; import org.labkey.api.settings.AdminConsole; import org.labkey.api.settings.AppProps; import org.labkey.api.util.GUID; @@ -2502,6 +2504,10 @@ public ApiResponse execute(AttachFilesForm form, BindException errors) if (null == wiki) throw new IllegalArgumentException("Could not find the wiki with entity id '" + form.getEntityId() + "'!"); + // Mutating a page's attachments requires UpdatePermission on the page + if (!getPermissions().allowUpdate(wiki)) + throw new UnauthorizedException("You do not have permission to modify this wiki page's attachments."); + if (!(getViewContext().getRequest() instanceof MultipartHttpServletRequest)) throw new IllegalArgumentException("You must use the 'multipart/form-data' mimetype when posting to attachFiles.api"); @@ -2932,29 +2938,66 @@ private WikiManager getWikiManager() public static class CopyWikiContainerScopingTestCase extends AbstractContainerScopingTest { + private Container _source; + private Container _dest; + + @Before + public void createWikiToCopy() + { + // Two folders and a wiki page that lives in the source. Each test makes a limited user an admin on exactly + // one of the folders to exercise a different side of CopyWikiAction's cross-container authorization. + _source = createContainer("Source"); + _dest = createContainer("Dest"); + WikiManager.get().insertWiki(getAdmin(), _source, "page", "body", WikiRendererType.HTML, "Page"); + } + @Test public void testCopyWikiRequiresSourceAdmin() throws Exception { - Container dest = createContainer("Dest"); - Container source = createContainer("Source"); + // Caller administers the destination only + assertCrossContainerCopyRejected(_dest); + } - // A user who is a folder admin in the destination only (no rights in the source) - User destAdminOnly = createUserInRole(dest, FolderAdminRole.class); + @Test + public void testCopyWikiRequiresDestAdmin() throws Exception + { + // Caller administers the source only + assertCrossContainerCopyRejected(_source); + } - // A wiki page that lives in the source folder - WikiManager.get().insertWiki(getAdmin(), source, "secretPage", "secret body", WikiRendererType.HTML, "Secret Page"); + // Drives a source->dest copy posted through callerAdminContainer an admin on BOTH folders must be + // accepted (redirect) and actually copy the page + private void assertCrossContainerCopyRejected(Container callerAdminContainer) throws Exception + { + User limitedAdmin = createUserInRole(callerAdminContainer, FolderAdminRole.class); + ActionURL url = new ActionURL(CopyWikiAction.class, callerAdminContainer) + .addParameter("sourceContainer", _source.getPath()) + .addParameter("destContainer", _dest.getPath()); - ActionURL url = new ActionURL(CopyWikiAction.class, dest) - .addParameter("sourceContainer", source.getPath()) - .addParameter("destContainer", dest.getPath()); - assertStatus(HttpServletResponse.SC_NOT_FOUND, post(url, destAdminOnly)); - assertTrue("No pages should have been copied into the destination", WikiSelectManager.getPageNames(dest).isEmpty()); + assertStatus(HttpServletResponse.SC_NOT_FOUND, post(url, limitedAdmin)); + assertTrue("No pages should have been copied into the destination", WikiSelectManager.getPageNames(_dest).isEmpty()); - // Positive control: the same copy by a user who is admin on BOTH folders is accepted (redirect to the - // destination) and actually copies the page, proving the guard rejects only the cross-container case - // rather than every copy. assertStatus(HttpServletResponse.SC_FOUND, post(url, getAdmin())); - assertFalse("Admin copy from a readable source should have copied the wiki page", WikiSelectManager.getPageNames(dest).isEmpty()); + assertFalse("An admin copy across both folders should have copied the wiki page", WikiSelectManager.getPageNames(_dest).isEmpty()); + } + + @Test + public void testAttachFilesRequiresUpdate() throws Exception + { + Container folder = createContainer("AttachFolder"); + WikiManager.get().insertWiki(getAdmin(), folder, "attachPage", "body", WikiRendererType.HTML, "Attach Page"); + String entityId = WikiSelectManager.getWiki(folder, "attachPage").getEntityId(); + + ActionURL url = new ActionURL(AttachFilesAction.class, folder).addParameter("entityId", entityId); + + // A Reader must not be able to delete/replace the page's attachments + User reader = createUserInRole(folder, ReaderRole.class); + assertStatus(HttpServletResponse.SC_FORBIDDEN, post(url, reader)); + + // Positive control: an Editor passes the UpdatePermission guard. + User editor = createUserInRole(folder, EditorRole.class); + assertNotEquals("An editor must pass the attachment UpdatePermission guard, not be blocked at 403", + HttpServletResponse.SC_FORBIDDEN, post(url, editor).getStatus()); } } } From 6ef777f2c3188a8ba1fd46224e3df6cfd2881394 Mon Sep 17 00:00:00 2001 From: Nick Kerr Date: Wed, 17 Jun 2026 12:39:40 -0700 Subject: [PATCH 11/17] Study container scope (#7762) --- .../api/data/AbstractParticipantCategory.java | 60 ++-- study/src/org/labkey/study/StudyModule.java | 2 +- .../study/controllers/DatasetController.java | 64 ++++ .../ParticipantGroupController.java | 90 +++--- .../study/controllers/StudyController.java | 14 +- .../study/model/ParticipantGroupManager.java | 290 ++++++++++++------ 6 files changed, 326 insertions(+), 194 deletions(-) diff --git a/api/src/org/labkey/api/data/AbstractParticipantCategory.java b/api/src/org/labkey/api/data/AbstractParticipantCategory.java index 00ab7ad438b..35e40383e13 100644 --- a/api/src/org/labkey/api/data/AbstractParticipantCategory.java +++ b/api/src/org/labkey/api/data/AbstractParticipantCategory.java @@ -15,6 +15,7 @@ */ package org.labkey.api.data; +import org.jetbrains.annotations.NotNull; import org.json.JSONArray; import org.json.JSONObject; import org.labkey.api.query.SimpleValidationError; @@ -226,18 +227,11 @@ public boolean canEdit(Container container, User user, List err { if (isNew()) return true; - else - { - User owner = UserManager.getUser(getCreatedBy()); - boolean allowed = - container.hasPermission(user, SharedParticipantGroupPermission.class) || - container.hasPermission(user, AdminPermission.class) || - (owner != null && !owner.isGuest() && owner.equals(user)); - - if (!allowed) - errors.add(new SimpleValidationError("You must be the owner to unshare this participant category")); - } + + if (!isOwner(user)) + errors.add(new SimpleValidationError("You must be the owner to unshare this participant category")); } + return errors.isEmpty(); } @@ -257,44 +251,28 @@ public boolean canDelete(Container container, User user, List e { if (isNew()) return true; - else - { - User owner = UserManager.getUser(getCreatedBy()); - boolean allowed = (owner != null && !owner.isGuest()) ? owner.equals(user) : false; - if (!allowed) - errors.add(new SimpleValidationError("You must be the owner to delete this participant category")); - } + if (!isOwner(user)) + errors.add(new SimpleValidationError("You must be the owner to delete this participant category")); } + return errors.isEmpty(); } - public boolean canRead(Container c, User user) + public boolean canRead(@NotNull User user) { - return canRead(c, user, new ArrayList<>()); + if (isShared() || isNew()) + return true; + + // Issue 16645: Do not show participant groups that may have been created by guests, which was possible + // before this bug was fixed. When admins can update and delete private groups, we can make + // guest-created groups visible again. + return isOwner(user); } - public boolean canRead(Container c, User user, List errors) + private boolean isOwner(@NotNull User user) { - if (!isShared()) - { - if (isNew()) - return true; - else - { - // issue 16645 : don't show participant groups that may have been created by guests, which was possible - // before this bug was fixed. When admins have the ability to update and delete private groups we can - // make guest created groups visible again. - User owner = UserManager.getUser(getCreatedBy()); - boolean allowed = (owner != null && !owner.isGuest()) ? owner.equals(user) : false; - - if (!allowed) - { - errors.add(new SimpleValidationError("You don't have permission to read this private participant category")); - return false; - } - } - } - return true; + User owner = UserManager.getUser(getCreatedBy()); + return owner != null && !owner.isGuest() && owner.equals(user); } } diff --git a/study/src/org/labkey/study/StudyModule.java b/study/src/org/labkey/study/StudyModule.java index 44a8ddb7a11..98f7579ea8a 100644 --- a/study/src/org/labkey/study/StudyModule.java +++ b/study/src/org/labkey/study/StudyModule.java @@ -713,13 +713,13 @@ public Set getIntegrationTests() return Set.of( DatasetDefinition.TestCleanupOrphanedDatasetDomains.class, DataStatesTest.class, - ParticipantGroupManager.ParticipantGroupTestCase.class, ParticipantGroupManager.ContainerScopingTestCase.class, StudyImpl.ProtocolDocumentTestCase.class, StudyManager.StudySnapshotTestCase.class, StudyManager.VisitCreationTestCase.class, StudyModule.TestCase.class, VisitImpl.TestCase.class, + DatasetController.DatasetAuditHistoryScopingTestCase.class, DatasetUpdateService.TestCase.class, DatasetLsidImportHelper.TestCase.class, CreateChildStudyAction.ContainerScopingTestCase.class, diff --git a/study/src/org/labkey/study/controllers/DatasetController.java b/study/src/org/labkey/study/controllers/DatasetController.java index 394cdf23fc8..bddda0a3331 100644 --- a/study/src/org/labkey/study/controllers/DatasetController.java +++ b/study/src/org/labkey/study/controllers/DatasetController.java @@ -17,8 +17,11 @@ package org.labkey.study.controllers; import org.apache.commons.lang3.StringUtils; +import org.junit.Before; +import org.junit.Test; import org.labkey.api.action.FormViewAction; import org.labkey.api.action.SimpleViewAction; +import org.labkey.api.audit.AbstractAuditTypeProvider; import org.labkey.api.audit.AuditLogService; import org.labkey.api.audit.permissions.CanSeeAuditLogPermission; import org.labkey.api.audit.view.AuditChangesView; @@ -26,11 +29,15 @@ import org.labkey.api.data.DataRegion; import org.labkey.api.data.DbScope; import org.labkey.api.security.RequiresPermission; +import org.labkey.api.security.User; +import org.labkey.api.security.permissions.AbstractContainerScopingTest; import org.labkey.api.security.permissions.AdminPermission; import org.labkey.api.security.permissions.ReadPermission; import org.labkey.api.study.EditDatasetRowForm; import org.labkey.api.study.InsertUpdateAction; import org.labkey.api.study.Study; +import org.labkey.api.study.TimepointType; +import org.labkey.api.util.GUID; import org.labkey.api.util.PageFlowUtil; import org.labkey.api.view.ActionURL; import org.labkey.api.view.HtmlView; @@ -42,6 +49,7 @@ import org.labkey.study.model.DatasetDefinition; import org.labkey.study.model.StudyImpl; import org.labkey.study.model.StudyManager; +import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.validation.BindException; import org.springframework.validation.Errors; import org.springframework.web.servlet.ModelAndView; @@ -118,6 +126,8 @@ public ModelAndView getView(DatasetAuditHistoryForm form, BindException errors) VBox view = new VBox(); + // getAuditEvent() resolves against the current container's audit schema (default ContainerFilter.Current + + // CanSeeAuditLog clause), so a foreign auditRowId resolves to null and cannot disclose a record in another folder. DatasetAuditProvider.DatasetAuditEvent event = AuditLogService.get().getAuditEvent(getUser(), DatasetAuditProvider.DATASET_AUDIT_EVENT, auditRowId); if (event != null) { @@ -283,4 +293,58 @@ public static class DatasetAuditHistoryForm public void setAuditRowId(int auditRowId) {this.auditRowId = auditRowId;} } + + public static class DatasetAuditHistoryScopingTestCase extends AbstractContainerScopingTest + { + private static final String FIELD_VALUE = GUID.makeGUID(); + private Container _requestContainer; + private long _foreignAuditRowId; + + @Before + public void setup() + { + _requestContainer = createContainer("Request"); + StudyImpl study = new StudyImpl(_requestContainer, "Request Study"); + study.setTimepointType(TimepointType.VISIT); + StudyManager.getInstance().createTestStudy(getAdmin(), study); + + _foreignAuditRowId = addDatasetAuditEvent(createContainer("Event")); + } + + @Test + public void doesNotDiscloseForeignFolderDatasetAuditEvent() throws Exception + { + // Even the site auditor, who may see audit logs everywhere, must not be served another folder's dataset + // audit record when requesting it through this folder's URL: the lookup is scoped to the request container. + String content = requestAuditHistory(_foreignAuditRowId, getAdmin()).getContentAsString(); + + assertFalse("A foreign auditRowId must not disclose dataset audit record in another folder", content.contains(FIELD_VALUE)); + assertTrue("Should fall through to the 'no additional details' view", content.contains("No additional details recorded")); + } + + @Test + public void disclosesOwnFolderDatasetAuditEvent() throws Exception + { + // Control: an event in the request folder must be shown, proving the negative case isn't passing simply + // because the view never renders record data. + long localRowId = addDatasetAuditEvent(_requestContainer); + String content = requestAuditHistory(localRowId, getAdmin()).getContentAsString(); + assertTrue("An audit event in the request folder must be shown", content.contains(FIELD_VALUE)); + } + + private long addDatasetAuditEvent(Container c) + { + DatasetAuditProvider.DatasetAuditEvent event = new DatasetAuditProvider.DatasetAuditEvent(c, "test dataset audit", 1); + event.setNewRecordMap(AbstractAuditTypeProvider.encodeForDataMap(Map.of("SecretField", FIELD_VALUE))); + event = AuditLogService.get().addEvent(getAdmin(), event); + return event.getRowId(); + } + + private MockHttpServletResponse requestAuditHistory(long auditRowId, User user) throws Exception + { + ActionURL url = new ActionURL(DatasetAuditHistoryAction.class, _requestContainer) + .addParameter("auditRowId", auditRowId); + return get(url, user); + } + } } diff --git a/study/src/org/labkey/study/controllers/ParticipantGroupController.java b/study/src/org/labkey/study/controllers/ParticipantGroupController.java index b37e76997f2..ed5f2a7c16d 100644 --- a/study/src/org/labkey/study/controllers/ParticipantGroupController.java +++ b/study/src/org/labkey/study/controllers/ParticipantGroupController.java @@ -160,26 +160,21 @@ public static class UpdateParticipantCategory extends MutatingApiAction categories; if (form.getCategoryType() != null && form.getCategoryType().equals("manual")) { @@ -274,11 +269,10 @@ public ApiResponse execute(GetParticipantCategoriesForm form, BindException erro } JSONArray defs = new JSONArray(); - for (ParticipantCategoryImpl pc : categories) - { defs.put(pc.toJSON()); - } + + ApiSimpleResponse resp = new ApiSimpleResponse(); resp.put("success", true); resp.put("categories", defs); @@ -295,7 +289,6 @@ public ApiResponse execute(Object form, BindException errors) Container c = getContainer(); User u = getUser(); JSONArray jsonGroups = new JSONArray(); - ApiSimpleResponse resp = new ApiSimpleResponse(); for (ParticipantCategoryImpl category : ParticipantGroupManager.getInstance().getParticipantCategories(c, u)) { @@ -303,11 +296,12 @@ public ApiResponse execute(Object form, BindException errors) { if (group.hasLiveFilter()) { - jsonGroups.put(group.toJSON(false /*includeParticipants*/)); + jsonGroups.put(group.toJSON(false)); } } } + ApiSimpleResponse resp = new ApiSimpleResponse(); resp.put("success", true); resp.put("participantGroups", jsonGroups); return resp; @@ -537,7 +531,7 @@ public ApiResponse execute(BrowseGroupsForm form, BindException errors) { GroupType groupType = GroupType.valueOf(type); Set selectedParticipants = new HashSet<>(); - switch(groupType) + switch (groupType) { case participantGroup: // the api will support either requesting a specific participant category/group or all of @@ -545,7 +539,8 @@ public ApiResponse execute(BrowseGroupsForm form, BindException errors) if (form.getCategoryId() != -1) { ParticipantCategoryImpl category = ParticipantGroupManager.getInstance().getParticipantCategory(getContainer(), getUser(), form.getCategoryId()); - addCategory(form, category, groups); + if (category != null) + addCategory(form, category, groups); } else if (form.getGroupId() != -1) { @@ -558,6 +553,7 @@ else if (form.getGroupId() != -1) // NOTE: This is intentional as 'personal' groups are not secured and can be shared. group = ParticipantGroupManager.getInstance().getParticipantGroupFromGroupRowId(getContainer(), getUser(), form.getGroupId()); } + if (group != null) { ParticipantCategoryImpl category = ParticipantGroupManager.getInstance().getParticipantCategory(getContainer(), getUser(), group.getCategoryId()); @@ -620,11 +616,8 @@ private void addCategory(BrowseGroupsForm form, ParticipantCategoryImpl category { if (form.isIncludePrivateGroups() || category.isShared()) { - Set selectedParticipants = new HashSet<>(); - for (ParticipantGroup group : category.getGroups()) { - selectedParticipants.addAll(group.getParticipantSet()); JSONGroup jsonGroup = new JSONGroup(group, category); if (form.includeParticipantIds()) jsonGroup.setParticipantIds(group.getParticipantSet()); @@ -1114,16 +1107,15 @@ public ApiResponse execute(ParticipantGroupSpecification form, BindException err } /** - * Code to check whether an implicitly created category needs to be deleted so we don't accumulate orpaned list type + * Code to check whether an implicitly created category needs to be deleted so we don't accumulate orphaned list type * categories. - * */ private void deleteImplicitCategory(Integer prevCategoryId, ParticipantCategoryImpl current) throws ValidationException { if (prevCategoryId != null) { ParticipantCategoryImpl oldCategory = ParticipantGroupManager.getInstance().getParticipantCategory(getContainer(), getUser(), prevCategoryId); - if (oldCategory.getType().equals("list") && !current.getType().equals("list")) + if (oldCategory != null && "list".equals(oldCategory.getType()) && !"list".equals(current.getType())) { ParticipantGroupManager.getInstance().deleteParticipantCategory(getContainer(), getUser(), oldCategory); } diff --git a/study/src/org/labkey/study/controllers/StudyController.java b/study/src/org/labkey/study/controllers/StudyController.java index fe3cc9c94ae..fa8b9d980f1 100644 --- a/study/src/org/labkey/study/controllers/StudyController.java +++ b/study/src/org/labkey/study/controllers/StudyController.java @@ -2953,12 +2953,16 @@ public static class DatasetItemDetailsAction extends SimpleViewAction("/org/labkey/study/view/sendParticipantGroup.jsp", form, errors); diff --git a/study/src/org/labkey/study/model/ParticipantGroupManager.java b/study/src/org/labkey/study/model/ParticipantGroupManager.java index 7a148b7210a..e62017e5bd6 100644 --- a/study/src/org/labkey/study/model/ParticipantGroupManager.java +++ b/study/src/org/labkey/study/model/ParticipantGroupManager.java @@ -16,7 +16,7 @@ package org.labkey.study.model; import org.jetbrains.annotations.Nullable; -import org.junit.Assert; +import org.junit.Before; import org.junit.Test; import org.labkey.api.audit.AuditLogService; import org.labkey.api.collections.CaseInsensitiveHashMap; @@ -31,7 +31,6 @@ import org.labkey.api.data.SQLFragment; import org.labkey.api.data.SimpleFilter; import org.labkey.api.data.SqlExecutor; -import org.labkey.api.data.SqlSelector; import org.labkey.api.data.Table; import org.labkey.api.data.TableInfo; import org.labkey.api.data.TableSelector; @@ -42,9 +41,9 @@ import org.labkey.api.query.ValidationException; import org.labkey.api.security.User; import org.labkey.api.security.permissions.AbstractContainerScopingTest; -import org.labkey.api.security.roles.ReaderRole; import org.labkey.api.security.permissions.AdminPermission; import org.labkey.api.security.permissions.ReadPermission; +import org.labkey.api.security.roles.ReaderRole; import org.labkey.api.settings.ResourceURL; import org.labkey.api.study.CohortFilter; import org.labkey.api.study.ParticipantCategory; @@ -78,17 +77,11 @@ import java.util.Collections; import java.util.HashMap; import java.util.HashSet; -import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.CopyOnWriteArrayList; -/** - * User: klum - * Date: Jun 1, 2011 - * Time: 2:26:02 PM - */ public class ParticipantGroupManager { private static final ParticipantGroupManager _instance = new ParticipantGroupManager(); @@ -119,17 +112,13 @@ public static TableInfo getTableInfoParticipantCategory() return StudySchema.getInstance().getTableInfoParticipantCategory(); } - public ParticipantCategoryImpl getParticipantCategory(Container c, User user, String label) + public @Nullable ParticipantCategoryImpl getParticipantCategory(Container c, User user, String label) { ParticipantCategoryImpl category = ParticipantGroupCache.getParticipantCategoryForLabel(c, label); - if (category != null) + if (category != null && category.canRead(user)) return category; - ParticipantCategoryImpl def = new ParticipantCategoryImpl(); - def.setContainer(c.getId()); - def.setLabel(label); - - return def; + return null; } public boolean categoryExists(Container c, User user, String label, boolean shared) @@ -137,14 +126,14 @@ public boolean categoryExists(Container c, User user, String label, boolean shar assert label != null : "Label cannot be null"; SimpleFilter filter = SimpleFilter.createContainerFilter(c); - Set exsitingCategories = new HashSet<>(); + Set existingCategories = new HashSet<>(); filter.addCondition(FieldKey.fromString("OwnerId"), shared ? ParticipantCategory.OWNER_SHARED : user.getUserId()); TableSelector selector = new TableSelector(getTableInfoParticipantCategory(), Collections.singleton("Label"), filter, null); for (String name : selector.getArrayList(String.class)) - exsitingCategories.add(name.toLowerCase()); + existingCategories.add(name.toLowerCase()); - return exsitingCategories.contains(label.toLowerCase()); + return existingCategories.contains(label.toLowerCase()); } public List getParticipantCategoriesByType(final Container c, final User user, @Nullable String type) @@ -152,12 +141,11 @@ public List getParticipantCategoriesByType(final Contai if (type == null) return _getParticipantCategories(c, user); - List categories = new ArrayList<>(); Collection types = ParticipantGroupCache.getParticipantCategoryForType(c, type); - if (types != null) - categories.addAll(types); + if (types == null) + return Collections.emptyList(); - return categories; + return types.stream().filter(category -> category != null && category.canRead(user)).toList(); } public List getParticipantCategoriesByLabel(final Container c, final User user, @Nullable String label) @@ -165,12 +153,11 @@ public List getParticipantCategoriesByLabel(final Conta if (label == null) return _getParticipantCategories(c, user); - List categories = new ArrayList<>(); - ParticipantCategoryImpl category = ParticipantGroupCache.getParticipantCategoryForLabel(c, label); + ParticipantCategoryImpl category = getParticipantCategory(c, user, label); if (category != null) - categories.add(category); + return List.of(category); - return categories; + return Collections.emptyList(); } public ActionButton createParticipantGroupButton(ViewContext context, String dataRegionName, CohortFilter cohortFilter, @@ -406,31 +393,26 @@ private String createNewParticipantGroupScript(ViewContext context, String dataR } /** - * Returns the list participant categories that the specified user is allowed to see + * Returns the list of participant categories that the specified user is allowed to see. * - * @param distinctCategories if true returns the unique (by label) set of categories. A private category will + * @param distinctCategories if true, returns the unique (by label) set of categories. A private category will * supersede a public category. */ public List getParticipantCategories(Container c, User user, boolean distinctCategories) { - if (distinctCategories) - { - Map categoryMap = new HashMap<>(); - for (ParticipantCategoryImpl category : _getParticipantCategories(c, user)) - { - if (categoryMap.containsKey(category.getLabel())) - { - if (!category.isShared()) - categoryMap.put(category.getLabel(), category); - } - else - categoryMap.put(category.getLabel(), category); - } - return new LinkedList<>(categoryMap.values()); + List categories = _getParticipantCategories(c, user); + + if (!distinctCategories) + return categories; + Map categoryMap = new HashMap<>(); + for (ParticipantCategoryImpl category : categories) + { + if (!categoryMap.containsKey(category.getLabel()) || !category.isShared()) + categoryMap.put(category.getLabel(), category); } - else - return _getParticipantCategories(c, user); + + return new ArrayList<>(categoryMap.values()); } public List getParticipantCategories(Container c, User user) @@ -440,14 +422,10 @@ public List getParticipantCategories(Container c, User private List _getParticipantCategories(Container c, User user) { - Collection categories = ParticipantGroupCache.getParticipantCategories(c); - List filtered = new ArrayList<>(); - - // TODO: Switch ParticipantCategoryImpl internals from arrays to lists... but not right now - categories.stream().filter(category -> category.canRead(c, user)).forEach(category -> { - filtered.add(category); - }); - return filtered; + return ParticipantGroupCache.getParticipantCategories(c) + .stream() + .filter(category -> category.canRead(user)) + .toList(); } @Deprecated // create participant categories and groups separately @@ -559,7 +537,7 @@ private ParticipantCategoryImpl _saveParticipantCategory(Container c, User user, } else { - ParticipantCategoryImpl prev = getParticipantCategory(c, user, def.getRowId()); + ParticipantCategoryImpl prev = getParticipantCategory(c, def.getRowId()); ret = Table.update(user, StudySchema.getInstance().getTableInfoParticipantCategory(), def, def.getRowId()); event = ParticipantGroupAuditProvider.EventFactory.categoryChange(c, user, prev, ret); } @@ -577,12 +555,9 @@ private ParticipantGroup _setParticipantGroup(Container c, User user, Participan if (verifyCategory) { ParticipantCategoryImpl cat = getParticipantCategory(c, user, group.getCategoryId()); - if (cat == null) throw new ValidationException("The specified category was not found."); - // canEdit enforces the SharedParticipantGroupPermission/Admin check for shared categories AND the - // owner check for private categories if (!cat.canEdit(c, user)) throw new ValidationException("You do not have permission to modify groups in this participant category"); } @@ -848,7 +823,6 @@ private void addGroupParticipants(Container c, User user, ParticipantGroup group } } - private void removeGroupParticipants(Container c, User user, ParticipantGroup group, String[] participantsToRemove) { // remove the mapping from group to participants @@ -861,7 +835,6 @@ private void removeGroupParticipants(Container c, User user, ParticipantGroup gr ParticipantGroupCache.uncache(c); } - private void deleteGroupParticipants(Container c, User user, ParticipantGroup group) { // remove the mapping from group to participants @@ -873,12 +846,20 @@ private void deleteGroupParticipants(Container c, User user, ParticipantGroup gr ParticipantGroupCache.uncache(c); } - @Nullable - public ParticipantCategoryImpl getParticipantCategory(Container c, User user, int rowId) + public @Nullable ParticipantCategoryImpl getParticipantCategory(Container c, int rowId) { return ParticipantGroupCache.getParticipantCategory(c, rowId); } + public @Nullable ParticipantCategoryImpl getParticipantCategory(Container c, User user, int rowId) + { + ParticipantCategoryImpl category = getParticipantCategory(c, rowId); + if (category != null && category.canRead(user)) + return category; + + return null; + } + public ParticipantGroup getParticipantGroup(Container container, User user, int rowId) { return ParticipantGroupCache.getParticipantGroup(container, rowId); @@ -893,21 +874,11 @@ public ParticipantGroup getParticipantGroupFromGroupRowId(Container container, U return selector.getObject(ParticipantGroup.class); } - public List getAllGroupedParticipants(Container container) - { - SQLFragment sql = new SQLFragment("SELECT DISTINCT ParticipantId FROM "); - sql.append(getTableInfoParticipantGroupMap(), "GroupMap"); - sql.append(" WHERE Container = ? ORDER BY ParticipantId"); - sql.add(container); - - return new SqlSelector(StudySchema.getInstance().getSchema(), sql).getArrayList(String.class); - } - public List getParticipantGroups(final Container c, User user, ParticipantCategoryImpl def) { if (!def.isNew()) { - ParticipantCategoryImpl category = ParticipantGroupCache.getParticipantCategory(c, def.getRowId()); + ParticipantCategoryImpl category = getParticipantCategory(c, def.getRowId()); if (category != null) return Arrays.stream(category.getGroups()).toList(); } @@ -1028,8 +999,10 @@ public void deleteParticipantCategory(Container c, User user, ParticipantCategor public void deleteParticipantGroup(Container c, User user, ParticipantGroup group) throws ValidationException { ParticipantCategoryImpl cat = getParticipantCategory(c, user, group.getCategoryId()); - List errors = new ArrayList<>(); + if (cat == null) + return; + List errors = new ArrayList<>(); if (!cat.canDelete(c, user, errors)) throw new ValidationException(errors); @@ -1168,19 +1141,21 @@ public void clearCache(Container c) ParticipantGroupCache.uncache(c); } - public static class ParticipantGroupTestCase extends Assert + public static class ContainerScopingTestCase extends AbstractContainerScopingTest { - @Test - public void test() + private static final ParticipantGroupManager MANAGER = ParticipantGroupManager.getInstance(); + private Container _container; + private User _owner; + private User _otherUser; + + @Before + public void setupParticipantGroupFixtures() throws Exception { - ParticipantGroupManager p = new ParticipantGroupManager(); - ParticipantCategoryImpl def = new ParticipantCategoryImpl(); - p.getParticipantGroups(null, null, def); + _container = createContainer("study"); + _owner = createUserInRole(_container, ReaderRole.class); + _otherUser = createUserInRole(_container, ReaderRole.class); } - } - public static class ContainerScopingTestCase extends AbstractContainerScopingTest - { @Test public void testSetParticipantGroupRequiresOwnership() throws Exception { @@ -1188,38 +1163,157 @@ public void testSetParticipantGroupRequiresOwnership() throws Exception // the category's creator -- but the manager previously enforced only the shared-category case, so a Read // user could overwrite another user's private group via a guessable rowId. The fix gates _setParticipantGroup // on canEdit() for the private case too. - Container folder = createContainer("A"); - StudyService.get().createStudy(folder, getAdmin(), "Study", TimepointType.VISIT, true); - insertParticipant(folder, "P1"); - - ParticipantGroupManager mgr = ParticipantGroupManager.getInstance(); + StudyService.get().createStudy(_container, getAdmin(), "Study", TimepointType.VISIT, true); + insertParticipant(_container, "P1"); // A PRIVATE participant category + group owned by the admin (ownerId != OWNER_SHARED makes it private; the // admin is its creator, so only the admin may edit it). ParticipantCategoryImpl cat = new ParticipantCategoryImpl(); - cat.setContainer(folder.getId()); + cat.setContainer(_container.getId()); cat.setLabel("private-category"); - cat.setType("list"); + cat.setType(ParticipantCategory.Type.list.name()); cat.setOwnerId(getAdmin().getUserId()); - cat = mgr.setParticipantCategory(folder, getAdmin(), cat, new String[]{"P1"}, null, "private"); - ParticipantGroup group = mgr.getParticipantGroups(folder, getAdmin(), cat).get(0); + cat = MANAGER.setParticipantCategory(_container, getAdmin(), cat, new String[]{"P1"}, null, "private"); + ParticipantGroup group = MANAGER.getParticipantGroups(_container, getAdmin(), cat).get(0); - // A different user with only Read access (not the owner, not an admin) - User attacker = createUserInRole(folder, ReaderRole.class); - - // Saving (overwriting) the admin's private group as the attacker must be rejected. + // Saving (overwriting) the admin's private group as a non-owner must be rejected. try { - mgr.setParticipantGroup(folder, attacker, group); + // A different user with only Read access (not the owner, not an admin) + MANAGER.setParticipantGroup(_container, _otherUser, group); fail("A non-owner must not be able to modify another user's private participant group"); } - catch (ValidationException expected) + catch (ValidationException ignored) { } // Positive control: the owner (admin) can still save their own private group -- the guard rejects only the // non-owner, not every caller. - mgr.setParticipantGroup(folder, getAdmin(), group); + MANAGER.setParticipantGroup(_container, getAdmin(), group); + } + + /** The by-label getter must apply canRead(), so by-label does not disclose a private category to a non-owner. */ + @Test + public void byLabelGetterHidesAnotherUsersPrivateCategory() throws Exception + { + ParticipantCategoryImpl owned = createPrivateCategory(_owner, "Private-by-label"); + + ParticipantCategoryImpl asOwner = MANAGER.getParticipantCategory(_container, _owner, "Private-by-label"); + assertNotNull("Owner should resolve their own private category", asOwner); + assertEquals("Owner should resolve the real (persisted) category", owned.getRowId(), asOwner.getRowId()); + assertFalse("Resolved category should be private", asOwner.isShared()); + assertEquals(_owner.getUserId(), asOwner.getOwnerId()); + + ParticipantCategoryImpl asOther = MANAGER.getParticipantCategory(_container, _otherUser, "Private-by-label"); + assertNull("Another user must not resolve the owner's private category by label", asOther); + } + + /** A shared category is readable by any user, so the by-label getter must still return it for a non-owner. */ + @Test + public void byLabelGetterReturnsSharedCategoryForAnyUser() throws Exception + { + ParticipantCategoryImpl shared = createSharedCategory("Shared-by-label"); + + ParticipantCategoryImpl asOther = MANAGER.getParticipantCategory(_container, _otherUser, "Shared-by-label"); + assertNotNull("A shared category must be readable by any user", asOther); + assertEquals(shared.getRowId(), asOther.getRowId()); + assertTrue(asOther.isShared()); + } + + /** The by-rowId getter must apply canRead(), so listing a categoryId does not disclose a private category to a non-owner. */ + @Test + public void byRowIdGetterHidesAnotherUsersPrivateCategory() throws Exception + { + ParticipantCategoryImpl owned = createPrivateCategory(_owner, "Private-by-rowId"); + + ParticipantCategoryImpl asOwner = MANAGER.getParticipantCategory(_container, _owner, owned.getRowId()); + assertNotNull("Owner should resolve their own private category by rowId", asOwner); + assertEquals(owned.getRowId(), asOwner.getRowId()); + assertFalse("Resolved category should be private", asOwner.isShared()); + assertEquals(_owner.getUserId(), asOwner.getOwnerId()); + + ParticipantCategoryImpl asOther = MANAGER.getParticipantCategory(_container, _otherUser, owned.getRowId()); + assertNull("Another user must not resolve the owner's private category by rowId", asOther); + } + + /** A shared category is readable by any user, so the by-rowId getter must still return it for a non-owner. */ + @Test + public void byRowIdGetterReturnsSharedCategoryForAnyUser() throws Exception + { + ParticipantCategoryImpl shared = createSharedCategory("Shared-by-rowId"); + + ParticipantCategoryImpl asOther = MANAGER.getParticipantCategory(_container, _otherUser, shared.getRowId()); + assertNotNull("A shared category must be readable by any user", asOther); + assertEquals(shared.getRowId(), asOther.getRowId()); + assertTrue(asOther.isShared()); + } + + /** getParticipantCategoriesByLabel delegates to the by-label getter and must inherit the same canRead() check. */ + @Test + public void byLabelListGetterHidesAnotherUsersPrivateCategory() throws Exception + { + createPrivateCategory(_owner, "Private-by-label-list"); + + assertEquals("Owner should see their private category by label", + 1, MANAGER.getParticipantCategoriesByLabel(_container, _owner, "Private-by-label-list").size()); + assertTrue("Another user must not see the owner's private category by label", + MANAGER.getParticipantCategoriesByLabel(_container, _otherUser, "Private-by-label-list").isEmpty()); + } + + /** getParticipantCategoriesByType must also filter by canRead(), so a private category isn't leaked by type. */ + @Test + public void byTypeGetterHidesAnotherUsersPrivateCategory() throws Exception + { + ParticipantCategoryImpl owned = createPrivateCategory(_owner, "Private-by-type"); + String type = ParticipantCategory.Type.list.name(); + + assertTrue("Owner should see their private category among the typed categories", + containsRowId(MANAGER.getParticipantCategoriesByType(_container, _owner, type), owned.getRowId())); + assertFalse("Another user must not see the owner's private category among the typed categories", + containsRowId(MANAGER.getParticipantCategoriesByType(_container, _otherUser, type), owned.getRowId())); + } + + /** The collection getter must hide another user's private category while still surfacing shared categories. */ + @Test + public void collectionGetterHidesPrivateButReturnsShared() throws Exception + { + ParticipantCategoryImpl owned = createPrivateCategory(_owner, "Private-collection"); + ParticipantCategoryImpl shared = createSharedCategory("Shared-collection"); + + List asOwner = MANAGER.getParticipantCategories(_container, _owner); + assertTrue("Owner should see their own private category", containsRowId(asOwner, owned.getRowId())); + assertTrue("Owner should see the shared category", containsRowId(asOwner, shared.getRowId())); + + List asOther = MANAGER.getParticipantCategories(_container, _otherUser); + assertFalse("Another user must not see the owner's private category", containsRowId(asOther, owned.getRowId())); + assertTrue("Shared categories remain visible to everyone", containsRowId(asOther, shared.getRowId())); + } + + private static boolean containsRowId(Collection categories, int rowId) + { + return categories.stream().anyMatch(category -> category.getRowId() == rowId); + } + + /** Create a private (owner-scoped) participant category, persisted and owned by {@code owner}. */ + private ParticipantCategoryImpl createPrivateCategory(User owner, String label) throws ValidationException + { + ParticipantCategoryImpl def = new ParticipantCategoryImpl(); + def.setContainer(_container.getId()); + def.setLabel(label); + def.setType(ParticipantCategory.Type.list.name()); + def.setOwnerId(owner.getUserId()); + return MANAGER.setParticipantCategory(_container, owner, def); + } + + /** Create a shared participant category. Created as the site admin, who may create shared categories. */ + private ParticipantCategoryImpl createSharedCategory(String label) throws ValidationException + { + ParticipantCategoryImpl def = new ParticipantCategoryImpl(); + def.setContainer(_container.getId()); + def.setLabel(label); + def.setType(ParticipantCategory.Type.list.name()); + // OwnerId defaults to OWNER_SHARED + return MANAGER.setParticipantCategory(_container, getAdmin(), def); } private void insertParticipant(Container c, String ptid) From ce4b290dc8ae0d9e20785036b6f146f4db172443 Mon Sep 17 00:00:00 2001 From: Binal Patel Date: Wed, 17 Jun 2026 15:35:44 -0600 Subject: [PATCH 12/17] Validate list audit event matches URL-requested list, and unit test. (#7757) - Pass an explicit ContainerFilter.current(_list.getContainer(), user) to getAuditEvent - Add ListAuditProvider.auditEventMatchesList() - Add ListAuditProvider.TestCase --- list/src/org/labkey/list/ListModule.java | 3 +- .../list/controllers/ListController.java | 9 +- .../labkey/list/model/ListAuditProvider.java | 83 +++++++++++++++++++ 3 files changed, 93 insertions(+), 2 deletions(-) diff --git a/list/src/org/labkey/list/ListModule.java b/list/src/org/labkey/list/ListModule.java index 6ca75a7db52..b8566275e9d 100644 --- a/list/src/org/labkey/list/ListModule.java +++ b/list/src/org/labkey/list/ListModule.java @@ -221,7 +221,8 @@ public Set getUnitTests() { return Set.of( ListManager.TestCase.class, - ListWriter.TestCase.class + ListWriter.TestCase.class, + ListAuditProvider.TestCase.class ); } } diff --git a/list/src/org/labkey/list/controllers/ListController.java b/list/src/org/labkey/list/controllers/ListController.java index 1cd6be9104c..9ee7bb13778 100644 --- a/list/src/org/labkey/list/controllers/ListController.java +++ b/list/src/org/labkey/list/controllers/ListController.java @@ -845,7 +845,14 @@ public ModelAndView getView(Object o, BindException errors) String oldRecord = null; String newRecord = null; - ListAuditProvider.ListAuditEvent event = AuditLogService.get().getAuditEvent(getUser(), ListManager.LIST_AUDIT_EVENT, id); + ListAuditProvider.ListAuditEvent event = AuditLogService.get().getAuditEvent( + getUser(), ListManager.LIST_AUDIT_EVENT, id, ContainerFilter.current(_list.getContainer(), getUser())); + + // Tie the loaded event to the URL-requested listId — rowId is user-controlled (CWE-639). + if (!ListAuditProvider.auditEventMatchesList(event, listId, _list.getContainer())) + { + event = null; + } if (event != null) { diff --git a/list/src/org/labkey/list/model/ListAuditProvider.java b/list/src/org/labkey/list/model/ListAuditProvider.java index 87415abdf58..11fcd2f8b81 100644 --- a/list/src/org/labkey/list/model/ListAuditProvider.java +++ b/list/src/org/labkey/list/model/ListAuditProvider.java @@ -34,13 +34,19 @@ import org.labkey.api.query.FieldKey; import org.labkey.api.query.UserSchema; import org.labkey.api.util.StringExpressionFactory; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.junit.Assert; +import org.junit.Test; import java.util.ArrayList; import java.util.Collections; +import java.util.Date; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; /** @@ -142,6 +148,22 @@ public Class getEventClass() return (Class)ListAuditEvent.class; } + /** + * Verifies that a loaded {@link ListAuditEvent} actually pertains to the requested list and container before its + * old/new record maps are surfaced to the caller. + *

+ * Called by {@code ListController.ListItemDetailsAction} to close CWE-639 (IDOR via user-controlled {@code rowId}): + * without this predicate, a user with audit-read access in container A could pass {@code listId=X&rowId=N-for-Y} + * and have List Y's audit payload render inside List X's details page. The container check is defense-in-depth + * in case the audit schema's default ContainerFilter is ever changed away from {@code Current}. + */ + public static boolean auditEventMatchesList(@Nullable ListAuditEvent event, int expectedListId, @NotNull Container expectedContainer) + { + return event != null + && event.getListId() == expectedListId + && Objects.equals(event.getContainer(), expectedContainer); + } + public static class ListAuditEvent extends DetailedAuditTypeEvent { private int _listId; @@ -214,6 +236,67 @@ public Map getAuditLogMessageElements() } } + public static class TestCase extends Assert + { + private static final String CONTAINER_A = "11111111-1111-1111-1111-111111111111"; + private static final String CONTAINER_B = "22222222-2222-2222-2222-222222222222"; + + @Test + public void matches_whenListIdAndContainerAgree() + { + Container c = testContainer(CONTAINER_A); + assertTrue(auditEventMatchesList(eventFor(42, c), 42, c)); + } + + @Test + public void rejects_nullEvent() + { + assertFalse(auditEventMatchesList(null, 42, testContainer(CONTAINER_A))); + } + + @Test + public void rejects_wrongListId() + { + // Event's listId is 99, but URL asked for list 42: the cross-list-in-same-container + // attack. Without this check, List 42's details page renders List 99's payload. + Container c = testContainer(CONTAINER_A); + assertFalse(auditEventMatchesList(eventFor(99, c), 42, c)); + } + + @Test + public void rejects_wrongContainer() + { + // Defense-in-depth: even if a future audit-schema CF change ever let an event from + // another container leak through getAuditEvent(), this check would still block it. + assertFalse(auditEventMatchesList( + eventFor(42, testContainer(CONTAINER_B)), 42, testContainer(CONTAINER_A))); + } + + @Test + public void rejects_nullEventContainer() + { + // Event present but its container is null (could happen if the audit event was + // hand-constructed or persisted without a container): must not match. + assertFalse(auditEventMatchesList(eventFor(42, null), 42, testContainer(CONTAINER_A))); + } + + private static Container testContainer(String guid) + { + // Container.equals compares the GUID id, so any non-null parent / name / rowId + // values are fine for this test; only the id field is read by the assertion. + return new Container(null, "junit-" + guid.substring(0, 8), guid, 0, 0, new Date(0), 0, false); + } + + private static ListAuditEvent eventFor(int listId, @Nullable Container container) + { + ListAuditEvent event = new ListAuditEvent(); + event.setListId(listId); + if (container != null) + event.setContainer(container); + return event; + } + } + public static class ListAuditDomainKind extends AbstractAuditDomainKind { public static final String NAME = "ListAuditDomain"; From 2dd91ea816e94eb3b52360a52a0ea7e50493feb0 Mon Sep 17 00:00:00 2001 From: Josh Eckels Date: Wed, 17 Jun 2026 16:47:44 -0700 Subject: [PATCH 13/17] Improve checks for file move and pipeline status API (#7768) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### Rationale Previous scoping changes weren't correct given some important use cases for compability. #### Changes - Don't consider an import of a file to prevent moving it via WebDAV - Allow users with read access to the parent container to access the pipeline job status API via any container #### Tasks 📍 - [x] Claude Code Review - ~Manual Testing~ - [x] Test Automation --- .../api/webdav/AbstractWebdavResource.java | 9 ++ .../labkey/api/webdav/FileSystemResource.java | 50 +++++-- .../org/labkey/api/webdav/WebdavResource.java | 8 + .../api/webdav/WebdavResourceReadOnly.java | 6 + .../org/labkey/core/webdav/DavController.java | 137 +++++++++++++++++- .../experiment/ScriptsResourceProvider.java | 6 + .../pipeline/PipelineWebdavProvider.java | 8 + .../pipeline/status/StatusController.java | 17 ++- 8 files changed, 215 insertions(+), 26 deletions(-) diff --git a/api/src/org/labkey/api/webdav/AbstractWebdavResource.java b/api/src/org/labkey/api/webdav/AbstractWebdavResource.java index 9bb1ccafa7c..44c267477a0 100644 --- a/api/src/org/labkey/api/webdav/AbstractWebdavResource.java +++ b/api/src/org/labkey/api/webdav/AbstractWebdavResource.java @@ -404,6 +404,15 @@ public boolean canDelete(User user, boolean forDelete, /* OUT */ @Nullable List< return perms.contains(DeletePermission.class); } + @Override + public boolean canMove(User user) + { + // A MOVE removes the resource from its source location, so by default it requires the same rights + // as deleting it from there. Resource types where moving and deleting differ (see FileSystemResource) + // override this. + return canDelete(user, true, null); + } + @Override public boolean canRename(User user, boolean forRename) { diff --git a/api/src/org/labkey/api/webdav/FileSystemResource.java b/api/src/org/labkey/api/webdav/FileSystemResource.java index 8a1b316a4f8..be7a88b24b8 100644 --- a/api/src/org/labkey/api/webdav/FileSystemResource.java +++ b/api/src/org/labkey/api/webdav/FileSystemResource.java @@ -507,27 +507,38 @@ public boolean canCreate(User user, boolean forCreate) } + // Shared access checks for canDelete() and canMove(): Delete permission plus a writable file on disk. + // Deliberately does NOT apply the "imported by an assay" restriction, which is specific to outright + // deletion - a file that has been imported may still be relocated within the file root. + private boolean hasDeletePermissionAndWritableFile(User user, boolean forDelete, @Nullable List message) + { + if (!super.canDelete(user, forDelete, message) || !hasFileSystem()) + return false; + File f = getFile(); + if (null == f) + return false; + if (!f.canWrite()) + { + SecurityLogger.log("File.canWrite()==false",user,null,false); + if (forDelete) + { + if (null != message) + message.add("File is not writable on server"); + _log.warn(user.getEmail() + " attempted to delete file that is not writable by LabKey Server. This may be a configuration problem. file: " + f.getPath()); + } + return false; + } + return true; + } + + @Override public boolean canDelete(User user, boolean forDelete, @Nullable List message) { try { - if (!super.canDelete(user, forDelete, message) || !hasFileSystem()) - return false; - File f = getFile(); - if (null == f) + if (!hasDeletePermissionAndWritableFile(user, forDelete, message)) return false; - if (!f.canWrite()) - { - SecurityLogger.log("File.canWrite()==false",user,null,false); - if (forDelete) - { - if (null != message) - message.add("File is not writable on server"); - _log.warn(user.getEmail() + " attempted to delete file that is not writable by LabKey Server. This may be a configuration problem. file: " + f.getPath()); - } - return false; - } // can't delete if already processed if (!getActions(user).isEmpty()) { @@ -544,6 +555,15 @@ public boolean canDelete(User user, boolean forDelete, @Nullable List me } + @Override + public boolean canMove(User user) + { + // A MOVE removes the file from its source location, so it requires Delete permission and a writable + // file. Unlike canDelete(), it does NOT require the file to be eligible for outright deletion: a file + // that has been imported by an assay may still be relocated within the file root. + return hasDeletePermissionAndWritableFile(user, false, null); + } + @Override public boolean canRename(User user, boolean forRename) { diff --git a/api/src/org/labkey/api/webdav/WebdavResource.java b/api/src/org/labkey/api/webdav/WebdavResource.java index 5b503d49f1f..db1592e4ccc 100644 --- a/api/src/org/labkey/api/webdav/WebdavResource.java +++ b/api/src/org/labkey/api/webdav/WebdavResource.java @@ -189,6 +189,14 @@ public interface WebdavResource extends Resource */ boolean canDelete(User user, boolean forDelete); boolean canDelete(User user, boolean forDelete, /* OUT */ List message); + /** + * A MOVE removes the resource from its source location, so it requires Delete permission there. + * Unlike {@link #canDelete}, it does not require the resource to be eligible for outright deletion + * (for example, a file that has been imported by an assay may still be relocated within the file root). + * @param user authenticated user + * @return true if the user has permission and server has capability + */ + boolean canMove(User user); /** * @param user authenticated user * @param forRename true if user wants to rename, false if checking capabilities (affects logging) diff --git a/api/src/org/labkey/api/webdav/WebdavResourceReadOnly.java b/api/src/org/labkey/api/webdav/WebdavResourceReadOnly.java index e5cdd8c3def..fe32e3fc7ce 100644 --- a/api/src/org/labkey/api/webdav/WebdavResourceReadOnly.java +++ b/api/src/org/labkey/api/webdav/WebdavResourceReadOnly.java @@ -291,6 +291,12 @@ public boolean canDelete(User user, boolean forDelete, List message) return false; } + @Override + public boolean canMove(User user) + { + return false; + } + @Override public boolean canRename(User user, boolean forRename) { diff --git a/core/src/org/labkey/core/webdav/DavController.java b/core/src/org/labkey/core/webdav/DavController.java index 4a71e7b134e..9af60ed35ff 100644 --- a/core/src/org/labkey/core/webdav/DavController.java +++ b/core/src/org/labkey/core/webdav/DavController.java @@ -50,12 +50,17 @@ import org.labkey.api.data.ContainerManager; import org.labkey.api.data.ConvertHelper; import org.labkey.api.data.Sort; +import org.labkey.api.exp.api.DataType; import org.labkey.api.exp.api.ExpData; +import org.labkey.api.exp.api.ExpProtocol; +import org.labkey.api.exp.api.ExpRun; import org.labkey.api.exp.api.ExperimentService; import org.labkey.api.files.FileContentService; import org.labkey.api.miniprofiler.MiniProfiler; import org.labkey.api.miniprofiler.RequestInfo; import org.labkey.api.module.ModuleLoader; +import org.labkey.api.pipeline.PipeRoot; +import org.labkey.api.pipeline.PipelineService; import org.labkey.api.premium.AntiVirusService; import org.labkey.api.premium.PremiumService; import org.labkey.api.query.ValidationException; @@ -3727,8 +3732,8 @@ WebdavStatus doMethod() throws DavException, IOException if (!src.canRead(getUser(), true)) return unauthorized(src); - // MOVE is effectively a delete operation from the source's perspective so confirm access - if (!src.canDelete(getUser(), true)) + // MOVE removes the resource from the source location, so confirm the caller could delete it there. + if (!src.canMove(getUser())) return unauthorized(src); if (exists && !dest.canWrite(getUser(),true) || !exists && !dest.canCreate(getUser(),true)) return unauthorized(dest); @@ -6727,11 +6732,91 @@ public void testMoveActionRequiresTargetCreate() throws Exception assertTrue("Destination file should exist after a successful move", FileUtil.appendName(targetDir, "moved.txt").exists()); } + // A.1: canDelete() and canMove() diverge ONLY for a file that backs experiment data (used by a run). + // Such a file may not be deleted outright, but it may still be relocated. This pins that divergence at + // the resource level, independent of the HTTP layer, as an admin who has every relevant permission. + @Test + public void testImportedFileCanMoveButNotDelete() throws Exception + { + File dir = ensureFilesDir(_folder); + File srcFile = writeFile(dir, "imported.txt"); + importFileIntoRun(_folder, srcFile); + + User admin = getAdmin(); + WebdavResource resource = WebdavService.get().lookup(filesPath(_folder).append("imported.txt")); + assertNotNull("Imported file should resolve through the resolver", resource); + assertTrue("Imported file should exist", resource.exists()); + assertFalse("Precondition: a file used by a run must report a non-empty action list", resource.getActions(admin).isEmpty()); + + List messages = new ArrayList<>(); + assertFalse("Admin must NOT be able to delete a file that has been imported by an assay", resource.canDelete(admin, true, messages)); + assertTrue("canDelete() should explain that the file was imported by an assay", messages.stream().anyMatch(m -> m.contains("imported by an assay"))); + assertTrue("Admin MUST still be able to move a file that has been imported by an assay", resource.canMove(admin)); + } + + // A.2: The same divergence end-to-end through WebdavServlet. A file that backs experiment data is refused + // by DELETE (SC_FORBIDDEN) but relocated by MOVE (SC_CREATED). This is the platform-level analog of the + // PanoramaPublicMoveSkyDocTest scenario and guards against a recurrence of that regression. + @Test + public void testMoveActionMovesImportedFileButDeleteForbidden() throws Exception + { + File dir = ensureFilesDir(_folder); + + // DELETE of a file imported by a run is forbidden, and the file survives. + File importedForDelete = writeFile(dir, "imported-delete.txt"); + importFileIntoRun(_folder, importedForDelete); + MockHttpServletResponse deleteResp = doDelete(_folder, filesPath(_folder).append("imported-delete.txt"), getAdmin()); + assertEquals("DELETE of a file imported by an assay must be forbidden", HttpServletResponse.SC_FORBIDDEN, deleteResp.getStatus()); + assertTrue("File must still exist after a forbidden delete", importedForDelete.exists()); + + // MOVE of an equally-imported file succeeds, relocating it within the file root. + File importedForMove = writeFile(dir, "imported-move.txt"); + importFileIntoRun(_folder, importedForMove); + Path src = filesPath(_folder).append("imported-move.txt"); + Path dest = filesPath(_folder).append("moved.txt"); + MockHttpServletResponse moveResp = doMove(_folder, src, dest, getAdmin()); + assertEquals("MOVE of a file imported by an assay must succeed", HttpServletResponse.SC_CREATED, moveResp.getStatus()); + assertFalse("Source file should no longer exist after a successful move", importedForMove.exists()); + assertTrue("Destination file should exist after a successful move", new File(dir, "moved.txt").exists()); + } + + // Regression guard for the canMove()/canDelete() divergence introduced alongside the MOVE fix. A + // FileSystemResource subclass whose canDelete() forbids deletion outright (PipelineFolderResource returns + // false) must also forbid MOVE. Otherwise the new FileSystemResource.canMove() default - Delete permission + // plus a writable file - would let an admin relocate a node that was never deletable, because that default + // calls super.canDelete() and so bypasses the subclass override entirely. + @Test + public void testNonDeletableNodeIsNotMovable() throws Exception + { + // Configure an explicit pipeline root so the @pipeline webdav node resolves to a PipelineFolderResource. + File fileRoot = ensureFilesDir(_folder).getParentFile(); + File pipelineDir = new File(fileRoot, "pipelineOverrideRoot"); + if (!pipelineDir.exists()) + assertTrue("Test requires a writable pipeline root directory", pipelineDir.mkdirs()); + PipelineService.get().setPipelineRoot(getAdmin(), _folder, PipelineService.PRIMARY_ROOT, false, pipelineDir.toURI()); + + WebdavResource pipelineNode = WebdavService.get().lookup(pipelinePath(_folder)); + assertNotNull("Test requires the @pipeline webdav node to resolve to a PipelineFolderResource", pipelineNode); + assertNotNull("The pipeline node must be backed by a writable file root", pipelineNode.getFile()); + + User admin = getAdmin(); + // An admin has Delete permission and the pipeline root is writable, so the inherited + // FileSystemResource.canMove() would return true. The override must keep canMove() aligned with the + // categorical canDelete()==false, so the node remains immovable. + assertFalse("The pipeline node must never be deletable", pipelineNode.canDelete(admin, true, null)); + assertFalse("A node that is not deletable must also not be movable", pipelineNode.canMove(admin)); + } + private static Path filesPath(Container c) { return WebdavService.getPath().append(c.getParsedPath()).append(FileContentService.FILES_LINK); } + private static Path pipelinePath(Container c) + { + return WebdavService.getPath().append(c.getParsedPath()).append(FileContentService.PIPELINE_LINK); + } + private static File ensureFilesDir(Container c) { WebdavResource filesNode = WebdavService.get().lookup(filesPath(c)); @@ -6745,7 +6830,12 @@ private static File ensureFilesDir(Container c) private static File writeFile(File dir) throws IOException { - File f = FileUtil.appendName(dir, "secret.txt"); + return writeFile(dir, "secret.txt"); + } + + private static File writeFile(File dir, String name) throws IOException + { + File f = FileUtil.appendName(dir, name); try (FileOutputStream os = new FileOutputStream(f)) { os.write("secret".getBytes(StandardCharsets.UTF_8)); @@ -6753,6 +6843,47 @@ private static File writeFile(File dir) throws IOException return f; } + // Registers the file as experiment data used by a run, so the resolver's WebdavResource reports a + // non-empty action list - the same state that, in production, blocks deletion of an imported file + // while still permitting a move. + private void importFileIntoRun(Container c, File file) throws Exception + { + ExperimentService expSvc = ExperimentService.get(); + User admin = getAdmin(); + + ExpData data = expSvc.createData(c, new DataType("Data"), file.getName()); + data.setDataFileURI(file.toURI()); + data.save(admin); + + ExpRun run = expSvc.createExperimentRun(c, "import of " + file.getName()); + PipeRoot pipeRoot = PipelineService.get().findPipelineRoot(c); + assertNotNull("Test requires a pipeline root for " + c.getName(), pipeRoot); + run.setFilePathRoot(pipeRoot.getRootPath()); + ExpProtocol protocol = expSvc.ensureSampleDerivationProtocol(admin); + run.setProtocol(protocol); + + ViewBackgroundInfo info = new ViewBackgroundInfo(c, admin, null); + // Wiring the data as a run input is enough for getRunsUsingDatas() to associate the run with the file. + expSvc.saveSimpleExperimentRun(run, Collections.emptyMap(), Map.of(data, "Data"), Collections.emptyMap(), + Collections.emptyMap(), Collections.emptyMap(), info, null, false); + } + + private static MockHttpServletResponse doDelete(Container sourceContainer, Path srcResource, User user) throws Exception + { + String srcWebdav = srcResource.toString(); + String servletPath = "/" + WebdavService.getServletPath(); + + HttpServletRequest base = ViewServlet.mockRequest("DELETE", new ActionURL(name, "delete", sourceContainer), user, Map.of(), null); + MockHttpServletRequest req = (MockHttpServletRequest) base; + req.setServletPath(servletPath); + req.setPathInfo(srcWebdav.substring(servletPath.length())); + req.setRequestURI(srcWebdav); + + MockHttpServletResponse resp = new MockHttpServletResponse(); + new WebdavServlet(false).service(req, resp); + return resp; + } + private static MockHttpServletResponse doMove(Container sourceContainer, Path srcResource, Path destResource, User user) throws Exception { String srcWebdav = srcResource.toString(); diff --git a/experiment/src/org/labkey/experiment/ScriptsResourceProvider.java b/experiment/src/org/labkey/experiment/ScriptsResourceProvider.java index 0ec70c020ab..0cbc665d68e 100644 --- a/experiment/src/org/labkey/experiment/ScriptsResourceProvider.java +++ b/experiment/src/org/labkey/experiment/ScriptsResourceProvider.java @@ -146,6 +146,12 @@ public boolean canList(User user, boolean forRead) return hasAccess(user); } + @Override + public boolean canMove(User user) + { + return hasAccess(user); + } + @Override public boolean shouldIndex() { diff --git a/pipeline/src/org/labkey/pipeline/PipelineWebdavProvider.java b/pipeline/src/org/labkey/pipeline/PipelineWebdavProvider.java index dcd298bc74a..dc256cb85e6 100644 --- a/pipeline/src/org/labkey/pipeline/PipelineWebdavProvider.java +++ b/pipeline/src/org/labkey/pipeline/PipelineWebdavProvider.java @@ -106,6 +106,14 @@ public boolean canDelete(User user, boolean forDelete, List msg) return false; } + @Override + public boolean canMove(User user) + { + // The pipeline folder node is never deletable (see canDelete()), so it must not be movable either. + // FileSystemResource.canMove() relaxes only the assay-import restriction, not this categorical block. + return false; + } + @Override protected boolean hasAccess(User user) { diff --git a/pipeline/src/org/labkey/pipeline/status/StatusController.java b/pipeline/src/org/labkey/pipeline/status/StatusController.java index 1e5050799a8..eacd8bf1fc8 100644 --- a/pipeline/src/org/labkey/pipeline/status/StatusController.java +++ b/pipeline/src/org/labkey/pipeline/status/StatusController.java @@ -484,7 +484,7 @@ public Object execute(StatusDetailsForm form, BindException errors) throws Excep Container c = getContainerCheckAdmin(); PipelineStatusFile psf = getStatusFile(form.getRowId()); - if (psf == null || !getContainer().equals(psf.lookupContainer())) + if (psf == null || psf.lookupContainer() == null || !psf.lookupContainer().hasPermission(getUser(), ReadPermission.class)) throw new NotFoundException("Could not find status file for rowId " + form.getRowId()); var status = StatusDetailsBean.create(c, psf, form.getOffset(), form.getCount()); @@ -1120,15 +1120,16 @@ public void testStatusDetailsContainerScoping() throws Exception ActionURL foreignUrl = new ActionURL(StatusDetailsAction.class, folderA).addParameter("rowId", String.valueOf(rowId)); - // The API is scoped to its own container: addressing B's job through folder A is 404, regardless of the - // caller's rights in B. This is the case that fails without the fix (the unscoped action would serve B's - // job through folder A). - // A caller who can read folder A but NOT folder B: + // This API authorizes against the job's OWN container (folder B), not the container in the URL, so it + // intentionally supports referencing a job from another container -- but only for a caller who can read the + // container the job actually lives in. + // A caller who can read folder A but NOT folder B must still get 404: the job must not be revealed to + // someone without rights to its container. This is the case that fails without the fix. assertStatus(HttpServletResponse.SC_NOT_FOUND, get(foreignUrl, readerA)); - // ...and a site admin, who CAN read folder B, still gets 404 through folder A (no cross-container redirect). - assertStatus(HttpServletResponse.SC_NOT_FOUND, get(foreignUrl, admin)); + // A site admin, who CAN read folder B, is served B's job through folder A -- the supported cross-container reference. + assertStatus(HttpServletResponse.SC_OK, get(foreignUrl, admin)); - // Positive control: addressing the job through its own container still succeeds. + // Positive control: addressing the job through its own container also succeeds for a caller who can read it. ActionURL ownUrl = new ActionURL(StatusDetailsAction.class, folderB).addParameter("rowId", String.valueOf(rowId)); assertStatus(HttpServletResponse.SC_OK, get(ownUrl, admin)); } From 5190cda530e926452baea152286e1c56571a64c4 Mon Sep 17 00:00:00 2001 From: Karl Lum Date: Wed, 17 Jun 2026 17:29:18 -0700 Subject: [PATCH 14/17] Container scoping for miscellaneous server objects (#7767) --- .../publish/AbstractPublishConfirmAction.java | 9 ++ .../filecontent/FileContentController.java | 10 +- .../org/labkey/issue/IssuesController.java | 95 +++++++++++++- issues/src/org/labkey/issue/IssuesModule.java | 3 +- .../specimen/actions/SpecimenController.java | 10 +- .../report/SpecimenVisitReportParameters.java | 47 ++++--- study/src/org/labkey/study/StudyModule.java | 2 + .../PublishConfirmContainerScopingTest.java | 117 ++++++++++++++++++ .../reports/ReportsController.java | 42 ++++++- 9 files changed, 305 insertions(+), 30 deletions(-) create mode 100644 study/src/org/labkey/study/controllers/publish/PublishConfirmContainerScopingTest.java diff --git a/api/src/org/labkey/api/study/publish/AbstractPublishConfirmAction.java b/api/src/org/labkey/api/study/publish/AbstractPublishConfirmAction.java index aee4cb7f297..f4d214d5ae8 100644 --- a/api/src/org/labkey/api/study/publish/AbstractPublishConfirmAction.java +++ b/api/src/org/labkey/api/study/publish/AbstractPublishConfirmAction.java @@ -116,6 +116,9 @@ else if (!_targetStudy.hasPermission(getUser(), InsertPermission.class)) if (_targetStudy != null) { + if (!_targetStudy.hasPermission(getUser(), InsertPermission.class)) + errors.reject(SpringActionController.ERROR_MSG, "You do not have permission to link data to the study in " + _targetStudy.getPath() + "."); + Study study = StudyService.get().getStudy(_targetStudy); if (study == null) { @@ -336,6 +339,7 @@ private void attemptLinkage(FORM form, BindException errors, boolean missingStudy = false; boolean badVisitIds = false; boolean badDates = false; + boolean noPermissionStudy = false; int index = 0; for (int objectId : allObjects) { @@ -380,6 +384,9 @@ private void attemptLinkage(FORM form, BindException errors, } else { + if (selected && !rowLevelTargetStudy.hasPermission(getUser(), InsertPermission.class)) + noPermissionStudy = true; + postedTargetStudies.put(objectId, rowLevelTargetStudy.getId()); if (StudyPublishService.get().getTimepointType(rowLevelTargetStudy) == TimepointType.VISIT) @@ -442,6 +449,8 @@ private void attemptLinkage(FORM form, BindException errors, if (missingStudy) errors.reject(null, "You must specify a Target Study for all selected rows."); + if (noPermissionStudy) + errors.reject(null, "You do not have permission to link data to one or more of the selected target studies."); if (missingPtid) errors.reject(null, "You must specify a Participant ID for all selected rows."); if (missingVisitId) diff --git a/filecontent/src/org/labkey/filecontent/FileContentController.java b/filecontent/src/org/labkey/filecontent/FileContentController.java index 3f01703bb95..a6fe85c88c6 100644 --- a/filecontent/src/org/labkey/filecontent/FileContentController.java +++ b/filecontent/src/org/labkey/filecontent/FileContentController.java @@ -945,7 +945,15 @@ public void validateForm(SimpleApiJsonForm form, Errors errors) { FileContentServiceImpl fileContentService = FileContentServiceImpl.getInstance(); WebdavResource resource = fileContentService.getResource(String.valueOf(fileProps.get("id"))); - if (resource != null && !resource.getActions(getUser()).isEmpty()) + + // GitHub Issue 1243: check resource container + if (resource == null || !getContainer().getEntityId().equals(resource.getContainerId())) + { + errors.reject(ERROR_MSG, String.format(FILE_PROP_ERROR, "Invalid file", fileProps.get("id"))); + return; + } + + if (!resource.getActions(getUser()).isEmpty()) { errors.reject(ERROR_MSG, String.format(FILE_PROP_ERROR, resource.getName(), "has been previously processed, properties cannot be edited")); return; diff --git a/issues/src/org/labkey/issue/IssuesController.java b/issues/src/org/labkey/issue/IssuesController.java index ee2eb357a1a..8a90727cca3 100644 --- a/issues/src/org/labkey/issue/IssuesController.java +++ b/issues/src/org/labkey/issue/IssuesController.java @@ -28,6 +28,8 @@ import org.jetbrains.annotations.Nullable; import org.json.JSONArray; import org.json.JSONObject; +import org.junit.After; +import org.junit.Before; import org.junit.Test; import org.labkey.api.action.ApiResponse; import org.labkey.api.action.ApiSimpleResponse; @@ -106,6 +108,7 @@ import org.labkey.api.security.permissions.InsertPermission; import org.labkey.api.security.permissions.ReadPermission; import org.labkey.api.security.permissions.UpdatePermission; +import org.labkey.api.security.roles.EditorRole; import org.labkey.api.security.roles.FolderAdminRole; import org.labkey.api.security.roles.OwnerRole; import org.labkey.api.security.roles.ReaderRole; @@ -115,12 +118,12 @@ import org.labkey.api.util.DOM; import org.labkey.api.util.GUID; import org.labkey.api.util.HtmlString; +import org.labkey.api.util.InputBuilder; import org.labkey.api.util.JsonUtil; import org.labkey.api.util.PageFlowUtil; import org.labkey.api.util.Pair; import org.labkey.api.util.TestContext; import org.labkey.api.util.URLHelper; -import org.labkey.api.util.InputBuilder; import org.labkey.api.view.ActionURL; import org.labkey.api.view.HtmlView; import org.labkey.api.view.HttpView; @@ -151,6 +154,7 @@ import org.labkey.issue.view.IssuesListView; import org.springframework.beans.MutablePropertyValues; import org.springframework.beans.PropertyValues; +import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.util.LinkedCaseInsensitiveMap; import org.springframework.validation.BindException; import org.springframework.validation.Errors; @@ -1731,6 +1735,13 @@ public URLHelper getSuccessURL(IssuesDomainKindProperties form) } } + private static List getSelectableAssignmentGroups(Container c, User user) + { + return SecurityManager.getGroups(c.getProject(), true).stream() + .filter(group -> !group.isGuests() && (!group.isUsers() || user.hasRootAdminPermission())) + .toList(); + } + @Marshal(Marshaller.Jackson) @RequiresPermission(AdminPermission.class) public static class GetProjectGroupsAction extends ReadOnlyApiAction @@ -1740,7 +1751,7 @@ public Object execute(Object form, BindException errors) { List groups = new ArrayList<>(); - SecurityManager.getGroups(getContainer().getProject(), true).stream().filter(group -> !group.isGuests() && (!group.isUsers() || getUser().hasRootAdminPermission())).forEach(group -> { + getSelectableAssignmentGroups(getContainer(), getUser()).forEach(group -> { String displayText = (group.isProjectGroup() ? "" : "Site: ") + group.getName(); UserGroupForm userGroups = new UserGroupForm(); @@ -1764,8 +1775,11 @@ public Object execute(UserGroupForm form, BindException errors) if (null != form.getGroupId()) { + // ensure group is in scope for the container Group group = SecurityManager.getGroup(form.getGroupId()); - if (group != null) + boolean inScope = group != null && getSelectableAssignmentGroups(getContainer(), getUser()) + .stream().anyMatch(g -> g.getUserId() == group.getUserId()); + if (inScope) { for (User user : SecurityManager.getAllGroupMembers(group, MemberType.ACTIVE_USERS, group.isUsers())) { @@ -2458,4 +2472,79 @@ private static void ensureIssuesEnabled(Container c) } } } + + /** + * GitHub Issue #1243 regression test. + */ + public static class GetUsersForGroupScopingTestCase extends AbstractContainerScopingTest + { + private static final String PROJECT_A = "IssuesGroupScopingA"; + private static final String PROJECT_B = "IssuesGroupScopingB"; + + private Container _projectA; + private Group _groupA; + private Group _groupB; + private User _member; + + @Before + public void setup() throws Exception + { + deleteProjects(); + User admin = getAdmin(); + _projectA = ContainerManager.createContainer(ContainerManager.getRoot(), PROJECT_A, admin); + Container projectB = ContainerManager.createContainer(ContainerManager.getRoot(), PROJECT_B, admin); + + // Security groups must be associated with a project, so each group's container is its owning project. + _groupA = SecurityManager.createGroup(_projectA, "ScopingGroupA", admin); + _groupB = SecurityManager.createGroup(projectB, "ScopingGroupB", admin); + + // A user who can be assigned issues in project A (UpdatePermission via Editor) and is a member of both groups. + _member = createUserInRole(_projectA, EditorRole.class); + SecurityManager.addMember(_groupA, _member); + SecurityManager.addMember(_groupB, _member); + } + + @After + public void tearDown() + { + deleteProjects(); + } + + @Test + public void doesNotEnumerateAnotherProjectsGroupMembers() throws Exception + { + // group B is in a different project from the source request. + String content = requestUsersForGroup(_projectA, _groupB).getContentAsString(); + assertFalse("A group id from another project must not enumerate that group's members through this folder", + content.contains("\"userId\" : " + _member.getUserId())); + } + + @Test + public void enumeratesOwnProjectGroupMembers() throws Exception + { + // Control: the request project's own group resolves in scope and returns its members. + String content = requestUsersForGroup(_projectA, _groupA).getContentAsString(); + assertTrue("An in-project group id must enumerate its members", + content.contains("\"userId\" : " + _member.getUserId())); + } + + private MockHttpServletResponse requestUsersForGroup(Container project, Group group) throws Exception + { + ActionURL url = new ActionURL(GetUsersForGroupAction.class, project) + .addParameter("groupId", group.getUserId()); + return get(url, getAdmin()); + } + + private void deleteProjects() + { + User admin = getAdmin(); + for (String name : new String[]{PROJECT_A, PROJECT_B}) + { + Container c = ContainerManager.getForPath("/" + name); + if (c != null) + ContainerManager.deleteAll(c, admin); + } + _projectA = null; + } + } } diff --git a/issues/src/org/labkey/issue/IssuesModule.java b/issues/src/org/labkey/issue/IssuesModule.java index 06630d6dbad..dc9aad60385 100644 --- a/issues/src/org/labkey/issue/IssuesModule.java +++ b/issues/src/org/labkey/issue/IssuesModule.java @@ -195,7 +195,8 @@ public Set getIntegrationTests() { return Set.of( org.labkey.issue.model.IssueManager.TestCase.class, - org.labkey.issue.IssuesController.MoveActionContainerScopingTestCase.class + org.labkey.issue.IssuesController.MoveActionContainerScopingTestCase.class, + org.labkey.issue.IssuesController.GetUsersForGroupScopingTestCase.class ); } diff --git a/specimen/src/org/labkey/specimen/actions/SpecimenController.java b/specimen/src/org/labkey/specimen/actions/SpecimenController.java index 059d63a9fcf..1e917b547ef 100644 --- a/specimen/src/org/labkey/specimen/actions/SpecimenController.java +++ b/specimen/src/org/labkey/specimen/actions/SpecimenController.java @@ -1182,12 +1182,16 @@ public static class SpecimenEventsRedirectAction extends SimpleViewAction getIntegrationTests() DatasetController.DatasetAuditHistoryScopingTestCase.class, DatasetUpdateService.TestCase.class, DatasetLsidImportHelper.TestCase.class, + PublishConfirmContainerScopingTest.class, CreateChildStudyAction.ContainerScopingTestCase.class, StudyController.ContainerScopingTestCase.class, ReportsController.ContainerScopingTestCase.class); diff --git a/study/src/org/labkey/study/controllers/publish/PublishConfirmContainerScopingTest.java b/study/src/org/labkey/study/controllers/publish/PublishConfirmContainerScopingTest.java new file mode 100644 index 00000000000..5685ce2b157 --- /dev/null +++ b/study/src/org/labkey/study/controllers/publish/PublishConfirmContainerScopingTest.java @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2026 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.study.controllers.publish; + +import org.junit.Before; +import org.junit.Test; +import org.labkey.api.data.Container; +import org.labkey.api.exp.PropertyType; +import org.labkey.api.exp.api.ExpSampleType; +import org.labkey.api.exp.api.SampleTypeService; +import org.labkey.api.gwt.client.model.GWTPropertyDescriptor; +import org.labkey.api.security.User; +import org.labkey.api.security.permissions.AbstractContainerScopingTest; +import org.labkey.api.security.roles.EditorRole; +import org.labkey.api.study.StudyService; +import org.labkey.api.study.TimepointType; +import org.labkey.api.view.ActionURL; +import org.labkey.api.view.UnauthorizedException; +import org.labkey.api.view.ViewContext; +import org.springframework.validation.BindException; + +import java.util.List; + +public class PublishConfirmContainerScopingTest extends AbstractContainerScopingTest +{ + private Container _source; + private Container _target; + private ExpSampleType _sampleType; + + @Before + public void setup() throws Exception + { + _source = createContainer("Source"); + _target = createContainer("Target"); + + StudyService.get().createStudy(_target, getAdmin(), "STUDY-4 scoping target", TimepointType.DATE, false); + _sampleType = createSampleType(_source); + + } + + @Test + public void testConfirmRejectsTargetStudyWithoutInsertPermission() throws Exception + { + // Editor in the source folder only: holds InsertPermission where the action runs, but none in the target study + User editor = createUserInRole(_source, EditorRole.class); + + assertFalse("Linking into a target study the caller cannot insert into must be rejected", + validate(_source, _sampleType, _target, editor)); + } + + @Test + public void testConfirmAllowsTargetStudyWithInsertPermission() throws Exception + { + // Positive control: the same caller is also granted insert (Editor) in the target study, so the guard must not + // fire — proving it rejects only the cross-container case rather than every link + User editor = createUserInRole(_source, EditorRole.class); + grantRole(editor, _target, EditorRole.class); + + assertTrue("Linking into a target study the caller can insert into must be allowed", + validate(_source, _sampleType, _target, editor)); + } + + /** + * Run the publish-confirm form's validation as {@code user}, linking {@code sampleType} (in {@code source}) to the + * study in {@code target}, returns true if validation succeeds, false otherwise. + */ + private boolean validate(Container source, ExpSampleType sampleType, Container target, User user) + { + ActionURL url = new ActionURL("study", "sampleTypePublishConfirm", source); + ViewContext context = ViewContext.getMockViewContext(user, source, url, false); + + SampleTypePublishConfirmAction action = new SampleTypePublishConfirmAction(); + action.setViewContext(context); + + SampleTypePublishConfirmAction.SampleTypePublishConfirmForm form = new SampleTypePublishConfirmAction.SampleTypePublishConfirmForm(); + form.setViewContext(context); + form.setRowId(sampleType.getRowId()); + form.setTargetStudy(new String[]{target.getId()}); + form.setReturnUrl(url.toString()); + + try + { + BindException errors = new BindException(form, "form"); + action.validateCommand(form, errors); + } + catch (UnauthorizedException e) + { + return false; + } + return true; + } + + private ExpSampleType createSampleType(Container c) throws Exception + { + List props = List.of( + new GWTPropertyDescriptor("Name", "string"), + new GWTPropertyDescriptor("Date", PropertyType.DATE.getTypeUri()), + new GWTPropertyDescriptor("PTID", "string") + ); + + return SampleTypeService.get().createSampleType(c, getAdmin(), "STUDY4ScopingSamples", null, + props, List.of(), -1,-1,-1,-1,null); + } +} diff --git a/study/src/org/labkey/study/controllers/reports/ReportsController.java b/study/src/org/labkey/study/controllers/reports/ReportsController.java index 65f56b36497..3aeedeaba80 100644 --- a/study/src/org/labkey/study/controllers/reports/ReportsController.java +++ b/study/src/org/labkey/study/controllers/reports/ReportsController.java @@ -70,6 +70,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.ReturnURLString; import org.labkey.api.util.TestContext; import org.labkey.api.util.URLHelper; import org.labkey.api.util.UniqueID; @@ -290,8 +291,14 @@ public boolean handlePost(SaveReportViewForm form, BindException errors) @Override public ActionURL getSuccessURL(SaveReportViewForm form) { - if (!StringUtils.isBlank(form.getRedirectUrl())) - return new ActionURL(form.getRedirectUrl()); + // Issue: redirectUrl is client-controlled. ReturnURLString validates it at bind time. + ReturnURLString redirectUrl = form.getRedirectUrl(); + if (redirectUrl != null && !redirectUrl.isEmpty()) + { + ActionURL url = redirectUrl.getActionURL(); + if (url != null) + return url; + } // after the save just redirect to the newly created view, ask the report for its run URL Report r = ReportService.get().getReport(getContainer(), _savedReportId); @@ -461,7 +468,8 @@ public ModelAndView getView(CrosstabDesignBean form, boolean reshow, BindExcepti bean.setQueryName(form.getQueryName()); bean.setDataRegionName(form.getDataRegionName()); bean.setViewName(form.getViewName()); - bean.setRedirectUrl(form.getRedirectUrl()); + if (form.getRedirectUrl() != null) + bean.setRedirectUrl(new ReturnURLString(form.getRedirectUrl())); bean.setErrors(errors); if (!getUser().isGuest()) @@ -836,7 +844,7 @@ public static class SaveReportViewForm extends SaveReportForm private String _schemaName; private String _viewName; private String _dataRegionName; - private String _redirectUrl; + private ReturnURLString _redirectUrl; public SaveReportViewForm() { @@ -926,12 +934,12 @@ public String getDataRegionName() return _dataRegionName; } - public String getRedirectUrl() + public ReturnURLString getRedirectUrl() { return _redirectUrl; } - public void setRedirectUrl(String redirectUrl) + public void setRedirectUrl(ReturnURLString redirectUrl) { _redirectUrl = redirectUrl; } @@ -1362,5 +1370,27 @@ controller.new ParticipantReportAction(), controller.new CreateQueryReportAction() ); } + + // GitHub Issue #1243 regression test. + @Test + public void redirectUrlConstrainedToAllowableHost() + { + SaveReportViewForm offHost = new SaveReportViewForm(); + offHost.setRedirectUrl(new ReturnURLString("https://evil.example.com/phish")); + assertNull("Off-host redirectUrl must not resolve to an off-site ActionURL", + offHost.getRedirectUrl().getActionURL()); + + SaveReportViewForm local = new SaveReportViewForm(); + local.setRedirectUrl(new ReturnURLString("/study-reports/begin.view?x=1")); + ActionURL localUrl = local.getRedirectUrl().getActionURL(); + assertNotNull("A local redirectUrl should resolve to an ActionURL", localUrl); + assertTrue("A local redirectUrl should be preserved same-origin", + localUrl.getLocalURIString().contains("begin.view")); + + SaveReportViewForm blank = new SaveReportViewForm(); + blank.setRedirectUrl(new ReturnURLString("")); + assertTrue("A blank redirectUrl should be empty so the action falls through to the run URL", + blank.getRedirectUrl().isEmpty()); + } } } From d2667da8b1ee3e81c114e896e4d7c8cc47fe28bb Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Thu, 18 Jun 2026 05:56:42 -0600 Subject: [PATCH 15/17] Scope mothership updateSoftwareRelease to the caller's container (#7763) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### Rationale MothershipManager.updateSoftwareRelease performed a raw Table.update keyed only on the SoftwareRelease primary key, with no container filter, and reassigned the row's container to the caller's folder. Because the update form binds softwareReleaseId directly from the request and UpdateAction only checks UpdatePermission against the current folder, a user with update rights in one folder could edit — and re-home into their own folder — a SoftwareRelease owned by another folder. This scopes the update to the caller's container so only rows that already belong to that folder can be modified. #### Related Pull Requests - None #### Changes - Add a container-scoped MothershipManager.getSoftwareRelease(int, Container) lookup, mirroring the existing getExceptionStackTrace/getServerInstallation helpers. - Verify the target SoftwareRelease belongs to the caller's container in updateSoftwareRelease before updating; throw NotFoundException otherwise. - Fix an incidental NPE in BulkUpdateAction where updateExceptionStackTrace was invoked even when the container-scoped lookup returned null; the call now happens only inside the null check. --- .../labkey/mothership/MothershipController.java | 2 +- .../org/labkey/mothership/MothershipManager.java | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/mothership/src/org/labkey/mothership/MothershipController.java b/mothership/src/org/labkey/mothership/MothershipController.java index 3d4e59cbc23..8b24d7a1c22 100644 --- a/mothership/src/org/labkey/mothership/MothershipController.java +++ b/mothership/src/org/labkey/mothership/MothershipController.java @@ -1599,8 +1599,8 @@ else if (!form.isIgnore()) { exceptionStackTrace.setBugNumber(-1); } + MothershipManager.get().updateExceptionStackTrace(exceptionStackTrace, getUser()); } - MothershipManager.get().updateExceptionStackTrace(exceptionStackTrace, getUser()); } catch (NumberFormatException e) { diff --git a/mothership/src/org/labkey/mothership/MothershipManager.java b/mothership/src/org/labkey/mothership/MothershipManager.java index 26eede9da92..1584030d4e2 100644 --- a/mothership/src/org/labkey/mothership/MothershipManager.java +++ b/mothership/src/org/labkey/mothership/MothershipManager.java @@ -42,6 +42,7 @@ import org.labkey.api.util.MothershipReport; import org.labkey.api.util.ReentrantLockWithName; import org.labkey.api.util.logging.LogHelper; +import org.labkey.api.view.NotFoundException; import java.io.IOException; import java.util.ArrayList; @@ -211,6 +212,13 @@ public SoftwareRelease ensureSoftwareRelease(Container container, String revisio } } + public SoftwareRelease getSoftwareRelease(int softwareReleaseId, Container container) + { + SimpleFilter filter = SimpleFilter.createContainerFilter(container); + filter.addCondition(FieldKey.fromString("SoftwareReleaseId"), softwareReleaseId); + return new TableSelector(getTableInfoSoftwareRelease(), filter, null).getObject(SoftwareRelease.class); + } + public ServerInstallation getServerInstallation(@NotNull String serverGUID, @NotNull String serverHostName, @NotNull Container c) { SimpleFilter filter = SimpleFilter.createContainerFilter(c); @@ -604,6 +612,12 @@ public void setStatusCakeApiKey(String statusCakeApiKey) public void updateSoftwareRelease(Container container, User user, SoftwareRelease bean) { + // Verify the target row actually belongs to this container before updating. The raw Table.update below is + // keyed only on the primary key, so without this check a user with UpdatePermission in one folder could edit + // (and, via setContainer, re-home) a SoftwareRelease owned by another folder. + if (getSoftwareRelease(bean.getSoftwareReleaseId(), container) == null) + throw new NotFoundException("SoftwareRelease not found in this folder: " + bean.getSoftwareReleaseId()); + bean.setContainer(container.getId()); Table.update(user, getTableInfoSoftwareRelease(), bean, bean.getSoftwareReleaseId()); } From 9087a57a15228c086dd1736e7fd08a3862834b4e Mon Sep 17 00:00:00 2001 From: Josh Eckels Date: Thu, 18 Jun 2026 10:22:16 -0700 Subject: [PATCH 16/17] Improve comments for tests in DavController (#7769) #### Rationale The fix for some scoping problems got committed with some overly verbose comments. To be merged when the queue is under less pressure. #### Changes - Edit the comments to be more relevant going forward --- .../org/labkey/core/webdav/DavController.java | 37 ++++++------------- 1 file changed, 12 insertions(+), 25 deletions(-) diff --git a/core/src/org/labkey/core/webdav/DavController.java b/core/src/org/labkey/core/webdav/DavController.java index 9af60ed35ff..cac04a360a6 100644 --- a/core/src/org/labkey/core/webdav/DavController.java +++ b/core/src/org/labkey/core/webdav/DavController.java @@ -6732,9 +6732,7 @@ public void testMoveActionRequiresTargetCreate() throws Exception assertTrue("Destination file should exist after a successful move", FileUtil.appendName(targetDir, "moved.txt").exists()); } - // A.1: canDelete() and canMove() diverge ONLY for a file that backs experiment data (used by a run). - // Such a file may not be deleted outright, but it may still be relocated. This pins that divergence at - // the resource level, independent of the HTTP layer, as an admin who has every relevant permission. + // A file that backs experiment data (used by a run) can be moved but not deleted. Test via Java API @Test public void testImportedFileCanMoveButNotDelete() throws Exception { @@ -6754,43 +6752,35 @@ public void testImportedFileCanMoveButNotDelete() throws Exception assertTrue("Admin MUST still be able to move a file that has been imported by an assay", resource.canMove(admin)); } - // A.2: The same divergence end-to-end through WebdavServlet. A file that backs experiment data is refused - // by DELETE (SC_FORBIDDEN) but relocated by MOVE (SC_CREATED). This is the platform-level analog of the - // PanoramaPublicMoveSkyDocTest scenario and guards against a recurrence of that regression. + // A file that backs experiment data (used by a run) can be moved but not deleted. Test via WebDAV HTTP request @Test public void testMoveActionMovesImportedFileButDeleteForbidden() throws Exception { File dir = ensureFilesDir(_folder); // DELETE of a file imported by a run is forbidden, and the file survives. - File importedForDelete = writeFile(dir, "imported-delete.txt"); - importFileIntoRun(_folder, importedForDelete); - MockHttpServletResponse deleteResp = doDelete(_folder, filesPath(_folder).append("imported-delete.txt"), getAdmin()); + File imported = writeFile(dir, "imported.txt"); + importFileIntoRun(_folder, imported); + MockHttpServletResponse deleteResp = doDelete(_folder, filesPath(_folder).append(imported.getName()), getAdmin()); assertEquals("DELETE of a file imported by an assay must be forbidden", HttpServletResponse.SC_FORBIDDEN, deleteResp.getStatus()); - assertTrue("File must still exist after a forbidden delete", importedForDelete.exists()); + assertTrue("File must still exist after a forbidden delete", imported.exists()); - // MOVE of an equally-imported file succeeds, relocating it within the file root. - File importedForMove = writeFile(dir, "imported-move.txt"); - importFileIntoRun(_folder, importedForMove); - Path src = filesPath(_folder).append("imported-move.txt"); + // MOVE of an imported file succeeds, relocating it within the file root. + Path src = filesPath(_folder).append(imported.getName()); Path dest = filesPath(_folder).append("moved.txt"); MockHttpServletResponse moveResp = doMove(_folder, src, dest, getAdmin()); assertEquals("MOVE of a file imported by an assay must succeed", HttpServletResponse.SC_CREATED, moveResp.getStatus()); - assertFalse("Source file should no longer exist after a successful move", importedForMove.exists()); - assertTrue("Destination file should exist after a successful move", new File(dir, "moved.txt").exists()); + assertFalse("Source file should no longer exist after a successful move", imported.exists()); + assertTrue("Destination file should exist after a successful move", FileUtil.appendName(dir, dest.getName()).exists()); } - // Regression guard for the canMove()/canDelete() divergence introduced alongside the MOVE fix. A - // FileSystemResource subclass whose canDelete() forbids deletion outright (PipelineFolderResource returns - // false) must also forbid MOVE. Otherwise the new FileSystemResource.canMove() default - Delete permission - // plus a writable file - would let an admin relocate a node that was never deletable, because that default - // calls super.canDelete() and so bypasses the subclass override entirely. + // Ensure special nodes like @pipeline can't be deleted or moved @Test public void testNonDeletableNodeIsNotMovable() throws Exception { // Configure an explicit pipeline root so the @pipeline webdav node resolves to a PipelineFolderResource. File fileRoot = ensureFilesDir(_folder).getParentFile(); - File pipelineDir = new File(fileRoot, "pipelineOverrideRoot"); + File pipelineDir = FileUtil.appendName(fileRoot, "pipelineOverrideRoot"); if (!pipelineDir.exists()) assertTrue("Test requires a writable pipeline root directory", pipelineDir.mkdirs()); PipelineService.get().setPipelineRoot(getAdmin(), _folder, PipelineService.PRIMARY_ROOT, false, pipelineDir.toURI()); @@ -6800,9 +6790,6 @@ public void testNonDeletableNodeIsNotMovable() throws Exception assertNotNull("The pipeline node must be backed by a writable file root", pipelineNode.getFile()); User admin = getAdmin(); - // An admin has Delete permission and the pipeline root is writable, so the inherited - // FileSystemResource.canMove() would return true. The override must keep canMove() aligned with the - // categorical canDelete()==false, so the node remains immovable. assertFalse("The pipeline node must never be deletable", pipelineNode.canDelete(admin, true, null)); assertFalse("A node that is not deletable must also not be movable", pipelineNode.canMove(admin)); } From 489b41f4427697a0cbff58031fb8b3200ab75931 Mon Sep 17 00:00:00 2001 From: Alan Vezina Date: Thu, 18 Jun 2026 17:07:30 -0500 Subject: [PATCH 17/17] Delete Assay DeleteAction (#7770) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Rationale The Assay DeleteAction is not used by our UI and is no longer needed. ## Related Pull Requests - ## Changes - Delete assay/actions/DeleteAction.java - Remove getDeleteDesignUrl from AssayUrls and AssayController ## Tasks 📍 - [x] Claude Code Review - [x] Manual Testing - [ ] ~Test Automation~ - [x] Verify Fix --- api/src/org/labkey/api/assay/AssayUrls.java | 2 - .../api/assay/actions/AssayHeaderView.java | 11 --- .../src/org/labkey/assay/AssayController.java | 10 --- .../labkey/assay/actions/DeleteAction.java | 74 ------------------- 4 files changed, 97 deletions(-) delete mode 100644 assay/src/org/labkey/assay/actions/DeleteAction.java diff --git a/api/src/org/labkey/api/assay/AssayUrls.java b/api/src/org/labkey/api/assay/AssayUrls.java index 805954917d0..ac91e567f8d 100644 --- a/api/src/org/labkey/api/assay/AssayUrls.java +++ b/api/src/org/labkey/api/assay/AssayUrls.java @@ -61,8 +61,6 @@ public interface AssayUrls extends UrlProvider ActionURL getChooseCopyDestinationURL(ExpProtocol protocol, Container container); - ActionURL getDeleteDesignURL(ExpProtocol protocol); - /** * Returns the URL for the assay import data wizard for an existing assay definition. * path and files may be null, in which case it is assumed that the POST will include data object RowIds diff --git a/api/src/org/labkey/api/assay/actions/AssayHeaderView.java b/api/src/org/labkey/api/assay/actions/AssayHeaderView.java index 1f2ffc8cfdc..8e2573c6e90 100644 --- a/api/src/org/labkey/api/assay/actions/AssayHeaderView.java +++ b/api/src/org/labkey/api/assay/actions/AssayHeaderView.java @@ -94,17 +94,6 @@ public List getLinks() return links; } - public static String getDeleteOnClick(ExpProtocol protocol, Container currentContainer) - { - ActionURL deleteURL = PageFlowUtil.urlProvider(AssayUrls.class).getDeleteDesignURL(protocol); - String extraWarning = ""; - if (!protocol.getContainer().equals(currentContainer)) - { - extraWarning = " It is defined in " + protocol.getContainer().getPath() + " and deleting it will remove it from all subfolders."; - } - return "if (window.confirm('Are you sure you want to delete this assay design and all of its runs?" + extraWarning + "')) { window.location = '" + deleteURL + "' }"; - } - public ExpProtocol getProtocol() { return _protocol; diff --git a/assay/src/org/labkey/assay/AssayController.java b/assay/src/org/labkey/assay/AssayController.java index 6f3c5819eed..b87693ba4ec 100644 --- a/assay/src/org/labkey/assay/AssayController.java +++ b/assay/src/org/labkey/assay/AssayController.java @@ -139,7 +139,6 @@ import org.labkey.assay.actions.AssayBatchDetailsAction; import org.labkey.assay.actions.AssayBatchesAction; import org.labkey.assay.actions.AssayResultsAction; -import org.labkey.assay.actions.DeleteAction; import org.labkey.assay.actions.DeleteProtocolAction; import org.labkey.assay.actions.GetAssayBatchAction; import org.labkey.assay.actions.GetAssayBatchesAction; @@ -174,7 +173,6 @@ import org.labkey.api.security.permissions.AbstractContainerScopingTest; import org.labkey.api.security.roles.EditorRole; import org.labkey.api.security.roles.ReaderRole; -import org.labkey.api.util.JunitUtil; import org.labkey.api.util.TestContext; import org.labkey.api.view.ViewBackgroundInfo; import org.labkey.api.view.ViewContext; @@ -212,7 +210,6 @@ public class AssayController extends SpringActionController AssayResultsAction.class, AssayRunDetailsAction.class, AssayRunsAction.class, - DeleteAction.class, DeleteProtocolAction.class, DesignerAction.class, GetAssayBatchAction.class, @@ -444,7 +441,6 @@ private static Map serializeAssayLinks(ExpProtocol protocol, Ass links.put("batches", urlProvider.getAssayBatchesURL(c, protocol, null)); links.put("begin", urlProvider.getProtocolURL(c, protocol, AssayBeginAction.class)); links.put("designCopy", urlProvider.getDesignerURL(c, protocol, true, null)); - links.put("designDelete", urlProvider.getDeleteDesignURL(protocol)); links.put("designEdit", urlProvider.getDesignerURL(c, protocol, false, null)); links.put("import", provider.getImportURL(c, protocol)); links.put("results", urlProvider.getAssayResultsURL(c, protocol)); @@ -1175,12 +1171,6 @@ public ActionURL getChooseCopyDestinationURL(ExpProtocol protocol, Container con return getProtocolURL(container, protocol, ChooseCopyDestinationAction.class); } - @Override - public ActionURL getDeleteDesignURL(ExpProtocol protocol) - { - return getProtocolURL(protocol.getContainer(), protocol, DeleteAction.class); - } - @Override public ActionURL getImportURL(Container container, ExpProtocol protocol, String path, File[] files) { diff --git a/assay/src/org/labkey/assay/actions/DeleteAction.java b/assay/src/org/labkey/assay/actions/DeleteAction.java deleted file mode 100644 index adae1bb54cc..00000000000 --- a/assay/src/org/labkey/assay/actions/DeleteAction.java +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright (c) 2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.labkey.assay.actions; - -import org.labkey.api.assay.AssayUrls; -import org.labkey.api.assay.actions.BaseAssayAction; -import org.labkey.api.assay.actions.ProtocolIdForm; -import org.labkey.api.assay.security.DesignAssayPermission; -import org.labkey.api.exp.api.ExpProtocol; -import org.labkey.api.security.RequiresPermission; -import org.labkey.api.security.User; -import org.labkey.api.security.permissions.DeletePermission; -import org.labkey.api.security.permissions.ReadPermission; -import org.labkey.api.util.PageFlowUtil; -import org.labkey.api.view.RedirectException; -import org.labkey.api.view.UnauthorizedException; -import org.labkey.api.view.ViewContext; -import org.springframework.validation.BindException; -import org.springframework.web.servlet.ModelAndView; - -import java.util.Set; - -/** - * User: brittp -* Date: Jul 26, 2007 -* Time: 7:23:24 PM -*/ -@RequiresPermission(ReadPermission.class) //will check explicitly in code below -public class DeleteAction extends BaseAssayAction -{ - @Override - public ModelAndView getView(ProtocolIdForm protocolIdForm, BindException errors) - { - ExpProtocol protocol; - try - { - protocol = protocolIdForm.getProtocol(); - } - catch (ProtocolIdForm.ProviderNotFoundException e) - { - // We're OK attempting to delete even though the AssayProvider can't be found - protocol = e.getProtocol(); - } - - if(!allowDelete(protocol)) - throw new UnauthorizedException("You do not have sufficient permissions to delete this assay design."); - - protocol.delete(getUser()); - throw new RedirectException(PageFlowUtil.urlProvider(AssayUrls.class).getAssayListURL(getContainer())); - } - - private boolean allowDelete(ExpProtocol protocol) - { - ViewContext ctx = getViewContext(); - User user = ctx.getUser(); - - //user must have both design assay AND delete permission, as this will delete both the design and uploaded data - return protocol.getContainer().hasPermissions(user, Set.of(DesignAssayPermission.class, DeletePermission.class)); - } -}