|
16 | 16 |
|
17 | 17 | package org.labkey.ehr; |
18 | 18 |
|
| 19 | +import jakarta.servlet.http.HttpServletRequest; |
19 | 20 | import org.apache.commons.lang3.StringUtils; |
20 | 21 | import org.apache.logging.log4j.Logger; |
21 | 22 | import org.jetbrains.annotations.NotNull; |
| 23 | +import org.junit.AfterClass; |
| 24 | +import org.junit.Assert; |
| 25 | +import org.junit.BeforeClass; |
| 26 | +import org.junit.Test; |
22 | 27 | import org.json.JSONArray; |
23 | 28 | import org.json.JSONObject; |
24 | 29 | import org.labkey.api.action.ApiResponse; |
25 | 30 | import org.labkey.api.action.ApiSimpleResponse; |
26 | 31 | import org.labkey.api.action.ConfirmAction; |
27 | 32 | import org.labkey.api.action.MutatingApiAction; |
| 33 | +import org.labkey.api.action.NullSafeBindException; |
28 | 34 | import org.labkey.api.action.ReadOnlyApiAction; |
29 | 35 | import org.labkey.api.action.SimpleErrorView; |
30 | 36 | import org.labkey.api.action.SimpleViewAction; |
|
38 | 44 | import org.labkey.api.data.DbScope; |
39 | 45 | import org.labkey.api.data.DisplayColumn; |
40 | 46 | import org.labkey.api.data.JsonWriter; |
| 47 | +import org.labkey.api.data.NormalContainerType; |
| 48 | +import org.labkey.api.data.PropertyManager; |
| 49 | +import org.labkey.api.data.PropertyManager.WritablePropertyMap; |
41 | 50 | import org.labkey.api.data.RuntimeSQLException; |
42 | 51 | import org.labkey.api.data.SimpleFilter; |
43 | 52 | import org.labkey.api.data.Sort; |
|
78 | 87 | import org.labkey.api.reader.TabLoader; |
79 | 88 | import org.labkey.api.resource.FileResource; |
80 | 89 | import org.labkey.api.resource.Resource; |
| 90 | +import org.labkey.api.security.MutableSecurityPolicy; |
81 | 91 | import org.labkey.api.security.RequiresPermission; |
| 92 | +import org.labkey.api.security.SecurityManager; |
| 93 | +import org.labkey.api.security.SecurityPolicyManager; |
| 94 | +import org.labkey.api.security.User; |
| 95 | +import org.labkey.api.security.UserManager; |
| 96 | +import org.labkey.api.security.ValidEmail; |
82 | 97 | import org.labkey.api.security.permissions.AdminPermission; |
83 | 98 | import org.labkey.api.security.permissions.DeletePermission; |
84 | 99 | import org.labkey.api.security.permissions.ReadPermission; |
85 | 100 | import org.labkey.api.security.permissions.UpdatePermission; |
| 101 | +import org.labkey.api.security.roles.FolderAdminRole; |
86 | 102 | import org.labkey.api.settings.AppProps; |
87 | 103 | import org.labkey.api.study.DatasetTable; |
88 | 104 | import org.labkey.api.util.ExceptionUtil; |
| 105 | +import org.labkey.api.util.GUID; |
89 | 106 | import org.labkey.api.util.HtmlStringBuilder; |
| 107 | +import org.labkey.api.util.JunitUtil; |
90 | 108 | import org.labkey.api.util.PageFlowUtil; |
91 | 109 | import org.labkey.api.util.Path; |
| 110 | +import org.labkey.api.util.TestContext; |
92 | 111 | import org.labkey.api.util.URLHelper; |
93 | 112 | import org.labkey.api.util.logging.LogHelper; |
94 | 113 | import org.labkey.api.view.ActionURL; |
|
97 | 116 | import org.labkey.api.view.NavTree; |
98 | 117 | import org.labkey.api.view.Portal; |
99 | 118 | import org.labkey.api.view.UnauthorizedException; |
| 119 | +import org.labkey.api.view.ViewContext; |
100 | 120 | import org.labkey.api.view.WebPartFactory; |
101 | 121 | import org.labkey.api.view.WebPartView; |
102 | 122 | import org.labkey.api.view.template.ClientDependency; |
@@ -722,6 +742,16 @@ public void setIds(String[] ids) |
722 | 742 | } |
723 | 743 | } |
724 | 744 |
|
| 745 | + /** |
| 746 | + * SECURITY — ELEVATED ACCESS BY DESIGN: |
| 747 | + * Demographics are served from EHRDemographicsService, whose cache is intentionally built under the configured |
| 748 | + * EHR service user (EHRService.getEHRUser(c)), NOT the requesting user. Callers therefore receive aggregated |
| 749 | + * demographic/derived fields regardless of their per-dataset / per-QCState row permissions. This is the intended |
| 750 | + * EHR design: Read access to an EHR study folder is the trust gate for the demographics summary, which backs the |
| 751 | + * core data-entry and animal-history UIs via the shared EHR.DemographicsCache client. See |
| 752 | + * {@link org.labkey.ehr.demographics.EHRDemographicsServiceImpl#getAnimals}. Do not expose the returned record map |
| 753 | + * to a lower trust boundary without re-securing it. |
| 754 | + */ |
725 | 755 | @RequiresPermission(ReadPermission.class) |
726 | 756 | public static class GetDemographicsAction extends ReadOnlyApiAction<GetDemographicsForm> |
727 | 757 | { |
@@ -773,6 +803,13 @@ public ApiResponse execute(ScheduleGeneticCalculationForm form, BindException er |
773 | 803 | errors.reject(ERROR_MSG, "Unable to find container for path: " + form.getContainerPath()); |
774 | 804 | return null; |
775 | 805 | } |
| 806 | + |
| 807 | + // The @RequiresPermission check only covers the request container; re-verify admin on the |
| 808 | + // resolved target so a folder admin can't redirect the (global) genetic-calc job at a |
| 809 | + // container they don't administer. |
| 810 | + if (!c.hasPermission(getUser(), AdminPermission.class)) |
| 811 | + throw new UnauthorizedException("You do not have permission to configure genetic calculations for container: " + c.getPath()); |
| 812 | + |
776 | 813 | GeneticCalculationsJob.setProperties(form.isEnabled(), c, form.getHourOfDay(), form.getDayOfWeek(), form.isKinshipValidation(), form.isAllowImportDuringBusinessHours()); |
777 | 814 |
|
778 | 815 | return new ApiSimpleResponse("success", true); |
@@ -2570,4 +2607,144 @@ public void setStartingId(Integer startingId) |
2570 | 2607 | _startingId = startingId; |
2571 | 2608 | } |
2572 | 2609 | } |
| 2610 | + |
| 2611 | + /** |
| 2612 | + * Verifies the cross-container authorization of {@link SetGeneticCalculationTaskSettingsAction}. The |
| 2613 | + * {@code @RequiresPermission(AdminPermission.class)} annotation only guards the request container, but the action |
| 2614 | + * resolves and acts on a caller-supplied {@code containerPath}. This test confirms a user who administers the |
| 2615 | + * request container but not the resolved target container is rejected, while a user who administers the target |
| 2616 | + * succeeds. |
| 2617 | + */ |
| 2618 | + public static class TestCase extends Assert |
| 2619 | + { |
| 2620 | + private static final String REQUEST_ADMIN_EMAIL = "[email protected]"; |
| 2621 | + private static final String BOTH_ADMIN_EMAIL = "[email protected]"; |
| 2622 | + |
| 2623 | + private static Container _requestContainer; |
| 2624 | + private static Container _targetContainer; |
| 2625 | + private static User _requestAdmin; // folder admin on the request container only |
| 2626 | + private static User _bothAdmin; // folder admin on both the request and target containers |
| 2627 | + |
| 2628 | + @BeforeClass |
| 2629 | + public static void setup() throws Exception |
| 2630 | + { |
| 2631 | + cleanup(); |
| 2632 | + |
| 2633 | + User testUser = TestContext.get().getUser(); |
| 2634 | + Container junit = JunitUtil.getTestContainer(); |
| 2635 | + _requestContainer = ContainerManager.createContainer(junit, "EHRGeneticCalcTest-request-" + GUID.makeGUID(), null, null, NormalContainerType.NAME, testUser); |
| 2636 | + _targetContainer = ContainerManager.createContainer(junit, "EHRGeneticCalcTest-target-" + GUID.makeGUID(), null, null, NormalContainerType.NAME, testUser); |
| 2637 | + |
| 2638 | + _requestAdmin = SecurityManager.addUser(new ValidEmail(REQUEST_ADMIN_EMAIL), null).getUser(); |
| 2639 | + _bothAdmin = SecurityManager.addUser(new ValidEmail(BOTH_ADMIN_EMAIL), null).getUser(); |
| 2640 | + |
| 2641 | + MutableSecurityPolicy requestPolicy = new MutableSecurityPolicy(_requestContainer, _requestContainer.getPolicy()); |
| 2642 | + requestPolicy.addRoleAssignment(_requestAdmin, FolderAdminRole.class); |
| 2643 | + requestPolicy.addRoleAssignment(_bothAdmin, FolderAdminRole.class); |
| 2644 | + SecurityPolicyManager.savePolicyForTests(requestPolicy, testUser); |
| 2645 | + |
| 2646 | + MutableSecurityPolicy targetPolicy = new MutableSecurityPolicy(_targetContainer, _targetContainer.getPolicy()); |
| 2647 | + targetPolicy.addRoleAssignment(_bothAdmin, FolderAdminRole.class); |
| 2648 | + SecurityPolicyManager.savePolicyForTests(targetPolicy, testUser); |
| 2649 | + } |
| 2650 | + |
| 2651 | + @Test |
| 2652 | + public void testCannotConfigureForeignContainer() |
| 2653 | + { |
| 2654 | + // _requestAdmin administers the request container but NOT the target container, so pointing the |
| 2655 | + // (global) genetic-calc schedule at the target container must be rejected. |
| 2656 | + SetGeneticCalculationTaskSettingsAction action = new SetGeneticCalculationTaskSettingsAction(); |
| 2657 | + action.setViewContext(makeContext(_requestAdmin, _requestContainer)); |
| 2658 | + |
| 2659 | + ScheduleGeneticCalculationForm form = new ScheduleGeneticCalculationForm(); |
| 2660 | + form.setEnabled(false); |
| 2661 | + form.setContainerPath(_targetContainer.getPath()); |
| 2662 | + form.setHourOfDay(2); |
| 2663 | + |
| 2664 | + try |
| 2665 | + { |
| 2666 | + action.execute(form, new NullSafeBindException(form, "form")); |
| 2667 | + fail("Expected UnauthorizedException when targeting a container the user does not administer"); |
| 2668 | + } |
| 2669 | + catch (UnauthorizedException expected) |
| 2670 | + { |
| 2671 | + // expected |
| 2672 | + } |
| 2673 | + } |
| 2674 | + |
| 2675 | + @Test |
| 2676 | + public void testCanConfigureAdministeredContainer() |
| 2677 | + { |
| 2678 | + // _bothAdmin administers the target container, so configuring the schedule for it must succeed. Preserve |
| 2679 | + // and restore the server-global settings this action mutates so the test leaves no side effects. |
| 2680 | + Map<String, String> original = new HashMap<>(PropertyManager.getProperties(GeneticCalculationsJob.GENETICCALCULATIONS_PROPERTY_DOMAIN)); |
| 2681 | + try |
| 2682 | + { |
| 2683 | + SetGeneticCalculationTaskSettingsAction action = new SetGeneticCalculationTaskSettingsAction(); |
| 2684 | + action.setViewContext(makeContext(_bothAdmin, _requestContainer)); |
| 2685 | + |
| 2686 | + ScheduleGeneticCalculationForm form = new ScheduleGeneticCalculationForm(); |
| 2687 | + form.setEnabled(false); // keep disabled so no Quartz job is actually scheduled |
| 2688 | + form.setContainerPath(_targetContainer.getPath()); |
| 2689 | + form.setHourOfDay(2); |
| 2690 | + |
| 2691 | + ApiResponse response = action.execute(form, new NullSafeBindException(form, "form")); |
| 2692 | + assertNotNull("Expected a response when configuring an administered container", response); |
| 2693 | + assertNotNull("Expected the target container to be persisted", GeneticCalculationsJob.getContainer()); |
| 2694 | + assertEquals("Schedule should target the requested container", _targetContainer.getId(), GeneticCalculationsJob.getContainer().getId()); |
| 2695 | + } |
| 2696 | + finally |
| 2697 | + { |
| 2698 | + WritablePropertyMap pm = PropertyManager.getWritableProperties(GeneticCalculationsJob.GENETICCALCULATIONS_PROPERTY_DOMAIN, true); |
| 2699 | + pm.clear(); |
| 2700 | + pm.putAll(original); |
| 2701 | + pm.save(); |
| 2702 | + // Resync the live scheduler to the restored settings. |
| 2703 | + GeneticCalculationsJob.unschedule(); |
| 2704 | + GeneticCalculationsJob.schedule(); |
| 2705 | + } |
| 2706 | + } |
| 2707 | + |
| 2708 | + private ViewContext makeContext(User u, Container c) |
| 2709 | + { |
| 2710 | + HttpServletRequest request = TestContext.get().getRequest(); |
| 2711 | + ViewContext context = new ViewContext(); |
| 2712 | + context.setContainer(c); |
| 2713 | + context.setUser(u); |
| 2714 | + context.setRequest(request); |
| 2715 | + return context; |
| 2716 | + } |
| 2717 | + |
| 2718 | + @AfterClass |
| 2719 | + public static void cleanup() |
| 2720 | + { |
| 2721 | + User testUser = TestContext.get().getUser(); |
| 2722 | + |
| 2723 | + if (_targetContainer != null) |
| 2724 | + { |
| 2725 | + ContainerManager.delete(_targetContainer, testUser); |
| 2726 | + _targetContainer = null; |
| 2727 | + } |
| 2728 | + if (_requestContainer != null) |
| 2729 | + { |
| 2730 | + ContainerManager.delete(_requestContainer, testUser); |
| 2731 | + _requestContainer = null; |
| 2732 | + } |
| 2733 | + |
| 2734 | + for (String email : new String[]{REQUEST_ADMIN_EMAIL, BOTH_ADMIN_EMAIL}) |
| 2735 | + { |
| 2736 | + try |
| 2737 | + { |
| 2738 | + User u = UserManager.getUser(new ValidEmail(email)); |
| 2739 | + if (u != null) |
| 2740 | + UserManager.deleteUser(u.getUserId()); |
| 2741 | + } |
| 2742 | + catch (ValidEmail.InvalidEmailException | SecurityManager.UserManagementException ignored) |
| 2743 | + { |
| 2744 | + } |
| 2745 | + } |
| 2746 | + _requestAdmin = null; |
| 2747 | + _bothAdmin = null; |
| 2748 | + } |
| 2749 | + } |
2573 | 2750 | } |
0 commit comments