Skip to content

Commit 02a5873

Browse files
committed
Merge from release25.7-SNAPSHOT
2 parents e8844b9 + 6ef777f commit 02a5873

38 files changed

Lines changed: 1782 additions & 272 deletions

File tree

announcements/src/org/labkey/announcements/AnnouncementModule.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,8 @@ public void startBackgroundThreads()
216216
public Set<Class> getIntegrationTests()
217217
{
218218
return Set.of(
219-
AnnouncementManager.TestCase.class
219+
AnnouncementManager.TestCase.class,
220+
ToursController.ContainerScopingTestCase.class
220221
);
221222
}
222223

announcements/src/org/labkey/announcements/ToursController.java

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515
*/
1616
package org.labkey.announcements;
1717

18+
import jakarta.servlet.http.HttpServletResponse;
1819
import org.json.JSONObject;
20+
import org.junit.Test;
1921
import org.labkey.announcements.model.TourManager;
2022
import org.labkey.announcements.model.TourModel;
2123
import org.labkey.announcements.query.AnnouncementSchema;
@@ -30,10 +32,17 @@
3032
import org.labkey.api.query.QueryView;
3133
import org.labkey.api.security.ActionNames;
3234
import org.labkey.api.security.RequiresPermission;
35+
import org.labkey.api.security.User;
36+
import org.labkey.api.security.permissions.AbstractContainerScopingTest;
37+
import org.labkey.api.security.permissions.AdminPermission;
3338
import org.labkey.api.security.permissions.ReadPermission;
39+
import org.labkey.api.security.roles.FolderAdminRole;
40+
import org.labkey.api.security.roles.ReaderRole;
3441
import org.labkey.api.view.ActionURL;
3542
import org.labkey.api.view.JspView;
3643
import org.labkey.api.view.NavTree;
44+
import org.labkey.api.view.UnauthorizedException;
45+
import org.springframework.mock.web.MockHttpServletResponse;
3746
import org.springframework.validation.BindException;
3847
import org.springframework.validation.Errors;
3948
import org.springframework.web.servlet.ModelAndView;
@@ -123,6 +132,12 @@ public static class SaveTourAction extends MutatingApiAction<SimpleApiJsonForm>
123132
@Override
124133
public void validateForm(SimpleApiJsonForm form, Errors errors)
125134
{
135+
// The "//will check below" gate on the annotation was never implemented: this action inserts/updates tour
136+
// content (a folder-level configuration asset) but performed no insert/update/admin check, so a Read user
137+
// could create or overwrite tours. Require folder admin to manage tours.
138+
if (!getContainer().hasPermission(getUser(), AdminPermission.class))
139+
throw new UnauthorizedException("You do not have permission to modify tours in this folder.");
140+
126141
TourModel model;
127142
JSONObject json = form.getJsonObject();
128143
int rowId = json.getInt("rowId");
@@ -203,4 +218,30 @@ public void setRowid(String rowid)
203218
_rowid = rowid;
204219
}
205220
}
221+
222+
public static class ContainerScopingTestCase extends AbstractContainerScopingTest
223+
{
224+
@Test
225+
public void testSaveTourRequiresAdmin() throws Exception
226+
{
227+
Container folder = createContainer("A");
228+
ActionURL url = new ActionURL(SaveTourAction.class, folder);
229+
230+
// A Reader must not be able to create/modify tours
231+
User reader = createUserInRole(folder, ReaderRole.class);
232+
JSONObject body = new JSONObject().put("rowId", -1);
233+
assertStatus(HttpServletResponse.SC_FORBIDDEN, postJson(url, reader, body));
234+
235+
// Positive control: a folder admin passes the permission gate and the tour is created (success, 200).
236+
User folderAdmin = createUserInRole(folder, FolderAdminRole.class);
237+
JSONObject adminBody = new JSONObject()
238+
.put("rowId", -1)
239+
.put("title", "scoping-test-tour")
240+
.put("description", "d")
241+
.put("mode", "0")
242+
.put("tour", new JSONObject());
243+
MockHttpServletResponse resp = postJson(url, folderAdmin, adminBody);
244+
assertStatus(HttpServletResponse.SC_OK, resp);
245+
}
246+
}
206247
}

api/src/org/labkey/api/data/AbstractParticipantCategory.java

Lines changed: 18 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
*/
1616
package org.labkey.api.data;
1717

18+
import org.jetbrains.annotations.NotNull;
1819
import org.json.JSONArray;
1920
import org.json.JSONObject;
2021
import org.labkey.api.query.SimpleValidationError;
@@ -226,15 +227,11 @@ public boolean canEdit(Container container, User user, List<ValidationError> err
226227
{
227228
if (isNew())
228229
return true;
229-
else
230-
{
231-
User owner = UserManager.getUser(getCreatedBy());
232-
boolean allowed = (owner != null && !owner.isGuest()) ? owner.equals(user) : false;
233230

234-
if (!allowed)
235-
errors.add(new SimpleValidationError("You must be the owner to unshare this participant category"));
236-
}
231+
if (!isOwner(user))
232+
errors.add(new SimpleValidationError("You must be the owner to unshare this participant category"));
237233
}
234+
238235
return errors.isEmpty();
239236
}
240237

@@ -254,44 +251,28 @@ public boolean canDelete(Container container, User user, List<ValidationError> e
254251
{
255252
if (isNew())
256253
return true;
257-
else
258-
{
259-
User owner = UserManager.getUser(getCreatedBy());
260-
boolean allowed = (owner != null && !owner.isGuest()) ? owner.equals(user) : false;
261254

262-
if (!allowed)
263-
errors.add(new SimpleValidationError("You must be the owner to delete this participant category"));
264-
}
255+
if (!isOwner(user))
256+
errors.add(new SimpleValidationError("You must be the owner to delete this participant category"));
265257
}
258+
266259
return errors.isEmpty();
267260
}
268261

269-
public boolean canRead(Container c, User user)
262+
public boolean canRead(@NotNull User user)
270263
{
271-
return canRead(c, user, new ArrayList<>());
264+
if (isShared() || isNew())
265+
return true;
266+
267+
// Issue 16645: Do not show participant groups that may have been created by guests, which was possible
268+
// before this bug was fixed. When admins can update and delete private groups, we can make
269+
// guest-created groups visible again.
270+
return isOwner(user);
272271
}
273272

274-
public boolean canRead(Container c, User user, List<ValidationError> errors)
273+
private boolean isOwner(@NotNull User user)
275274
{
276-
if (!isShared())
277-
{
278-
if (isNew())
279-
return true;
280-
else
281-
{
282-
// issue 16645 : don't show participant groups that may have been created by guests, which was possible
283-
// before this bug was fixed. When admins have the ability to update and delete private groups we can
284-
// make guest created groups visible again.
285-
User owner = UserManager.getUser(getCreatedBy());
286-
boolean allowed = (owner != null && !owner.isGuest()) ? owner.equals(user) : false;
287-
288-
if (!allowed)
289-
{
290-
errors.add(new SimpleValidationError("You don't have permission to read this private participant category"));
291-
return false;
292-
}
293-
}
294-
}
295-
return true;
275+
User owner = UserManager.getUser(getCreatedBy());
276+
return owner != null && !owner.isGuest() && owner.equals(user);
296277
}
297278
}

api/src/org/labkey/api/data/TableViewForm.java

Lines changed: 55 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import org.labkey.api.action.NullSafeBindException;
3535
import org.labkey.api.action.SpringActionController;
3636
import org.labkey.api.collections.CaseInsensitiveHashMap;
37+
import org.labkey.api.query.FieldKey;
3738
import org.labkey.api.query.SchemaKey;
3839
import org.labkey.api.security.permissions.DeletePermission;
3940
import org.labkey.api.security.permissions.InsertPermission;
@@ -183,8 +184,21 @@ public void doUpdate() throws SQLException
183184
throw new UnauthorizedException();
184185
}
185186

186-
if (null != _tinfo.getColumn("container"))
187+
FieldKey containerFK = FieldKey.fromParts("Container");
188+
if (null != _tinfo.getColumn(containerFK))
189+
{
190+
// The hasPermission() check above only proves the user can update the *current* container. The UPDATE below
191+
// keys on the PK alone and stamps the row into the current container, so without this guard a user with
192+
// update permission here could edit (and re-home) a row that actually lives in another container simply by
193+
// POSTing its PK. Confirm the existing row is in this container; 404 otherwise. PkFilter validates and
194+
// converts the PK as well, so a missing or malformed key likewise 404s here rather than later.
195+
SimpleFilter filter = new PkFilter(_tinfo, getPkVals());
196+
filter.addCondition(containerFK, _c.getId());
197+
if (!new TableSelector(_tinfo, filter, null).exists())
198+
throw new NotFoundException("No matching row found in this folder");
199+
187200
set("container", _c.getId());
201+
}
188202

189203
Object[] pkVal = getPkVals();
190204
Map<String, Object> newMap = Table.update(_user, _tinfo, getTypedValues(), pkVal);
@@ -207,21 +221,50 @@ public void doDelete()
207221
throw new UnauthorizedException();
208222
}
209223

210-
if (null != _selectedRows && _selectedRows.length > 0)
211-
{
212-
for (String selectedRow : _selectedRows)
213-
Table.delete(_tinfo, selectedRow);
214-
}
215-
else
224+
// Table.delete() keys on the PK alone. As with doUpdate(), the DeletePermission check only proves the user can
225+
// delete in the *current* container, so for container-scoped tables we must confirm each target row lives here;
226+
// otherwise a user could delete a row that belongs to another container by POSTing (or grid-selecting) its PK.
227+
FieldKey containerFK = FieldKey.fromParts("Container");
228+
boolean scopeToContainer = null != _tinfo.getColumn(containerFK);
229+
230+
try (DbScope.Transaction t = _tinfo.getSchema().getScope().ensureTransaction())
216231
{
217-
Object[] pkVal = getPkVals();
218-
if (null != pkVal && null != pkVal[0])
219-
Table.delete(_tinfo, pkVal);
220-
else //Hmm, throw an exception here????
221-
_log.warn("Nothing to delete for table " + _tinfo.getName() + " on request " + _request.getRequestURI());
232+
if (null != _selectedRows && _selectedRows.length > 0)
233+
{
234+
for (String selectedRow : _selectedRows)
235+
{
236+
if (scopeToContainer)
237+
deleteInContainer(selectedRow, containerFK);
238+
else
239+
Table.delete(_tinfo, selectedRow);
240+
}
241+
}
242+
else
243+
{
244+
Object[] pkVal = getPkVals();
245+
if (null != pkVal && null != pkVal[0])
246+
{
247+
if (scopeToContainer)
248+
deleteInContainer(pkVal, containerFK);
249+
else
250+
Table.delete(_tinfo, pkVal);
251+
}
252+
else //Hmm, throw an exception here????
253+
_log.warn("Nothing to delete for table " + _tinfo.getName() + " on request " + _request.getRequestURI());
254+
}
222255
}
223256
}
224257

258+
// Deletes a single row only if it lives in the form's container, scoping the DELETE's WHERE clause to the PK and the
259+
// container together. 404s on a miss (cross-container or already gone), mirroring doUpdate().
260+
private void deleteInContainer(Object pkVal, FieldKey containerFK)
261+
{
262+
SimpleFilter filter = new PkFilter(_tinfo, pkVal);
263+
filter.addCondition(containerFK, _c.getId());
264+
if (Table.delete(_tinfo, filter) == 0)
265+
throw new NotFoundException("No matching row found in this folder");
266+
}
267+
225268
/**
226269
* Pulls in the data from the current row of the database.
227270
*/

api/src/org/labkey/api/exp/OntologyManager.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2841,13 +2841,14 @@ public static Object getRemappedValueForLookup(User user, Container container, R
28412841
return cache.remap(SchemaKey.fromParts(lookup.getSchemaKey()), lookup.getQueryName(), user, lkContainer, ContainerFilter.Type.CurrentPlusProjectAndShared, String.valueOf(value));
28422842
}
28432843

2844-
public static List<PropertyUsages> findPropertyUsages(User user, List<Integer> propertyIds, int maxUsageCount)
2844+
public static List<PropertyUsages> findPropertyUsagesByIds(User user, Container container, List<Integer> propertyIds, int maxUsageCount)
28452845
{
28462846
List<PropertyUsages> ret = new ArrayList<>(propertyIds.size());
28472847
for (int propertyId : propertyIds)
28482848
{
28492849
var pd = getPropertyDescriptor(propertyId);
2850-
if (pd == null)
2850+
// Kanban #1924: Get property descriptors for the current container only
2851+
if (pd == null || !pd.getContainer().equals(container))
28512852
throw new IllegalArgumentException("property not found: " + propertyId);
28522853

28532854
ret.add(findPropertyUsages(user, pd, maxUsageCount));

api/src/org/labkey/api/security/permissions/AbstractContainerScopingTest.java

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
package org.labkey.api.security.permissions;
1717

1818
import jakarta.servlet.http.HttpServletResponse;
19+
import org.json.JSONObject;
1920
import org.junit.After;
2021
import org.junit.Assert;
2122
import org.labkey.api.data.Container;
@@ -57,6 +58,7 @@
5758
public abstract class AbstractContainerScopingTest extends Assert
5859
{
5960
private static final Map<String, Object> FORM_HEADERS = Map.of("Content-Type", "application/x-www-form-urlencoded");
61+
private static final Map<String, Object> JSON_HEADERS = Map.of("Content-Type", "application/json");
6062

6163
private final List<Container> _containers = new ArrayList<>();
6264
private final List<User> _users = new ArrayList<>();
@@ -75,7 +77,17 @@ protected User getAdmin()
7577
protected Container createContainer(String name)
7678
{
7779
Container junit = JunitUtil.getTestContainer();
78-
Container c = ContainerManager.ensureContainer(junit.getParsedPath().append(getClass().getSimpleName() + "-" + name, true), getAdmin());
80+
// Use the fully-qualified class name, not getSimpleName(): the nested test class is named
81+
// "ContainerScopingTestCase" in nearly every controller, so getSimpleName() would give them all the SAME
82+
// /_junit child path and they would share fixtures (and collide on unique constraints across runs). Sanitize
83+
// to a valid folder name, and force-delete any fixture an interrupted prior run left behind so each run starts
84+
// from a clean container even when a previous @After could not complete.
85+
String prefix = getClass().getName().replaceAll("[^A-Za-z0-9]", "_");
86+
var path = junit.getParsedPath().append(prefix + "-" + name, true);
87+
Container existing = ContainerManager.getForPath(path);
88+
if (existing != null)
89+
ContainerManager.deleteAll(existing, getAdmin());
90+
Container c = ContainerManager.ensureContainer(path, getAdmin());
7991
_containers.add(c);
8092
return c;
8193
}
@@ -124,6 +136,12 @@ protected MockHttpServletResponse post(ActionURL url, User user) throws Exceptio
124136
return ViewServlet.POST(url, user, FORM_HEADERS, null);
125137
}
126138

139+
/** Dispatch a JSON POST to a {@code @Marshal(Jackson)} API action, with {@code body} supplied as the request body. */
140+
protected MockHttpServletResponse postJson(ActionURL url, User user, JSONObject body) throws Exception
141+
{
142+
return ViewServlet.POST(url, user, JSON_HEADERS, body.toString());
143+
}
144+
127145
/** Assert that a dispatched response has the expected HTTP status code. */
128146
protected void assertStatus(int expected, HttpServletResponse response)
129147
{

api/src/org/labkey/api/study/publish/AbstractPublishConfirmAction.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
import org.labkey.api.view.ActionURL;
4343
import org.labkey.api.view.JspView;
4444
import org.labkey.api.view.RedirectException;
45+
import org.labkey.api.view.UnauthorizedException;
4546
import org.labkey.api.view.VBox;
4647
import org.labkey.api.view.template.ClientDependency;
4748
import org.springframework.validation.BindException;
@@ -107,6 +108,10 @@ public void validateCommand(FORM form, Errors errors)
107108
{
108109
errors.reject(SpringActionController.ERROR_MSG, "Could not find target study");
109110
}
111+
else if (!_targetStudy.hasPermission(getUser(), InsertPermission.class))
112+
{
113+
throw new UnauthorizedException("You do not have permission to insert into the target study");
114+
}
110115
}
111116

112117
if (_targetStudy != null)

0 commit comments

Comments
 (0)