From 5ddc6043413aa38bd32832fc37e8fc8d76e4129a Mon Sep 17 00:00:00 2001 From: sadnub Date: Thu, 28 Oct 2021 10:34:16 -0400 Subject: [PATCH] rework deployments ui, implement client/site permisssions, and tests --- .../0019_remove_deployment_client.py | 17 ++ api/tacticalrmm/clients/models.py | 7 +- api/tacticalrmm/clients/tests.py | 177 ++++++++++++++++-- api/tacticalrmm/clients/views.py | 21 ++- api/tacticalrmm/tacticalrmm/models.py | 2 +- web/src/api/clients.js | 20 ++ web/src/components/Deployment.vue | 136 -------------- web/src/components/FileBar.vue | 23 +-- web/src/components/clients/Deployment.vue | 171 +++++++++++++++++ web/src/components/clients/NewDeployment.vue | 132 +++++++++++++ .../modals/clients/NewDeployment.vue | 150 --------------- 11 files changed, 527 insertions(+), 329 deletions(-) create mode 100644 api/tacticalrmm/clients/migrations/0019_remove_deployment_client.py delete mode 100644 web/src/components/Deployment.vue create mode 100644 web/src/components/clients/Deployment.vue create mode 100644 web/src/components/clients/NewDeployment.vue delete mode 100644 web/src/components/modals/clients/NewDeployment.vue diff --git a/api/tacticalrmm/clients/migrations/0019_remove_deployment_client.py b/api/tacticalrmm/clients/migrations/0019_remove_deployment_client.py new file mode 100644 index 00000000..9a0695a9 --- /dev/null +++ b/api/tacticalrmm/clients/migrations/0019_remove_deployment_client.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.6 on 2021-10-28 00:12 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('clients', '0018_auto_20211010_0249'), + ] + + operations = [ + migrations.RemoveField( + model_name='deployment', + name='client', + ), + ] diff --git a/api/tacticalrmm/clients/models.py b/api/tacticalrmm/clients/models.py index d4940e26..31a3416f 100644 --- a/api/tacticalrmm/clients/models.py +++ b/api/tacticalrmm/clients/models.py @@ -265,9 +265,6 @@ class Deployment(models.Model): objects = PermissionQuerySet.as_manager() uid = models.UUIDField(primary_key=False, default=uuid.uuid4, editable=False) - client = models.ForeignKey( - "clients.Client", related_name="deployclients", on_delete=models.CASCADE - ) site = models.ForeignKey( "clients.Site", related_name="deploysites", on_delete=models.CASCADE ) @@ -286,6 +283,10 @@ class Deployment(models.Model): def __str__(self): return f"{self.client} - {self.site} - {self.mon_type}" + @property + def client(self): + return self.site.client + class ClientCustomField(models.Model): client = models.ForeignKey( diff --git a/api/tacticalrmm/clients/tests.py b/api/tacticalrmm/clients/tests.py index a5ca300d..0f940ad7 100644 --- a/api/tacticalrmm/clients/tests.py +++ b/api/tacticalrmm/clients/tests.py @@ -4,6 +4,7 @@ from itertools import cycle from model_bakery import baker from rest_framework.serializers import ValidationError +from rest_framework.response import Response from tacticalrmm.test import TacticalTestCase @@ -432,8 +433,8 @@ class TestClientViews(TacticalTestCase): self.check_not_authenticated("delete", url) - def test_generate_deployment(self): - # TODO complete this + @patch("tacticalrmm.utils.generate_winagent_exe", return_value=Response("ok")) + def test_generate_deployment(self, post): url = "/clients/asdkj234kasdasjd-asdkj234-asdk34-sad/deploy/" r = self.client.get(url) @@ -445,6 +446,14 @@ class TestClientViews(TacticalTestCase): r = self.client.get(url) self.assertEqual(r.status_code, 404) + # test valid download + deployment = baker.make("clients.Deployment", install_flags={"rdp": True, "ping": False, "power": False}) + + url = f"/clients/{deployment.uid}/deploy/" + + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + class TestClientPermissions(TacticalTestCase): def setUp(self): @@ -454,7 +463,7 @@ class TestClientPermissions(TacticalTestCase): def test_get_clients_permissions(self): # create user with empty role user = self.create_user_with_roles([]) - self.client.force_authenticate(user=user) + self.client.force_authenticate(user=user) # type: ignore url = f"{base_url}/" @@ -471,17 +480,17 @@ class TestClientPermissions(TacticalTestCase): # all agents should be returned response = self.check_authorized("get", url) - self.assertEqual(len(response.data), 5) + self.assertEqual(len(response.data), 5) # type: ignore # limit user to specific client. only 1 client should be returned user.role.can_view_clients.set([clients[3]]) response = self.check_authorized("get", url) - self.assertEqual(len(response.data), 1) + self.assertEqual(len(response.data), 1) # type: ignore # 2 should be returned now user.role.can_view_clients.set([clients[0], clients[1]]) response = self.check_authorized("get", url) - self.assertEqual(len(response.data), 2) + self.assertEqual(len(response.data), 2) # type: ignore # limit to a specific site. The site shouldn't be in client returned sites sites = baker.make("clients.Site", client=clients[4], _quantity=3) @@ -490,8 +499,8 @@ class TestClientPermissions(TacticalTestCase): user.role.can_view_sites.set([sites[0]]) response = self.check_authorized("get", url) - self.assertEqual(len(response.data), 3) - for client in response.data: + self.assertEqual(len(response.data), 3) # type: ignore + for client in response.data: # type: ignore if client["id"] == clients[0].id: self.assertEqual(len(client["sites"]), 4) elif client["id"] == clients[1].id: @@ -521,7 +530,7 @@ class TestClientPermissions(TacticalTestCase): self.check_authorized_superuser("post", url, data) user = self.create_user_with_roles([]) - self.client.force_authenticate(user=user) + self.client.force_authenticate(user=user) # type: ignore # test user without role self.check_not_authorized("post", url, data) @@ -536,7 +545,7 @@ class TestClientPermissions(TacticalTestCase): def test_get_edit_delete_clients_permissions(self, delete): # create user with empty role user = self.create_user_with_roles([]) - self.client.force_authenticate(user=user) + self.client.force_authenticate(user=user) # type: ignore client = baker.make("clients.Client") unauthorized_client = baker.make("clients.Client") @@ -572,7 +581,7 @@ class TestClientPermissions(TacticalTestCase): def test_get_sites_permissions(self): # create user with empty role user = self.create_user_with_roles([]) - self.client.force_authenticate(user=user) + self.client.force_authenticate(user=user) # type: ignore url = f"{base_url}/sites/" @@ -590,28 +599,28 @@ class TestClientPermissions(TacticalTestCase): # all sites should be returned response = self.check_authorized("get", url) - self.assertEqual(len(response.data), 10) + self.assertEqual(len(response.data), 10) # type: ignore # limit user to specific site. only 1 site should be returned user.role.can_view_sites.set([sites[3]]) response = self.check_authorized("get", url) - self.assertEqual(len(response.data), 1) + self.assertEqual(len(response.data), 1) # type: ignore # 2 should be returned now user.role.can_view_sites.set([sites[0], sites[1]]) response = self.check_authorized("get", url) - self.assertEqual(len(response.data), 2) + self.assertEqual(len(response.data), 2) # type: ignore # check if limiting user to client works user.role.can_view_sites.clear() user.role.can_view_clients.set([clients[0]]) response = self.check_authorized("get", url) - self.assertEqual(len(response.data), 4) + self.assertEqual(len(response.data), 4) # type: ignore # add a site to see if the results still work user.role.can_view_sites.set([sites[1], sites[0]]) response = self.check_authorized("get", url) - self.assertEqual(len(response.data), 5) + self.assertEqual(len(response.data), 5) # type: ignore # make sure superusers work self.check_authorized_superuser("get", url) @@ -632,7 +641,7 @@ class TestClientPermissions(TacticalTestCase): self.check_authorized_superuser("post", url, data) user = self.create_user_with_roles([]) - self.client.force_authenticate(user=user) + self.client.force_authenticate(user=user) # type: ignore # test user without role self.check_not_authorized("post", url, data) @@ -658,7 +667,7 @@ class TestClientPermissions(TacticalTestCase): def test_get_edit_delete_sites_permissions(self, delete): # create user with empty role user = self.create_user_with_roles([]) - self.client.force_authenticate(user=user) + self.client.force_authenticate(user=user) # type: ignore site = baker.make("clients.Site") unauthorized_site = baker.make("clients.Site") @@ -698,3 +707,135 @@ class TestClientPermissions(TacticalTestCase): # make sure superusers work for method in methods: self.check_authorized_superuser(method, f"{base_url}/{unauthorized_site.id}/") + + def test_get_pendingactions_permissions(self): + url = f"{base_url}/deployments/" + + site = baker.make("clients.Site") + other_site = baker.make("clients.Site") + deployments = baker.make("clients.Deployment", site=site, _quantity=5) + other_deployments = baker.make("clients.Deployment", site=other_site, _quantity=7) + + # test getting all deployments + # make sure superusers work + self.check_authorized_superuser("get", url) + + # create user with empty role + user = self.create_user_with_roles([]) + self.client.force_authenticate(user=user) # type: ignore + + # user with empty role should fail + self.check_not_authorized("get", url) + + # add can_list_sites roles and should succeed + user.role.can_list_deployments = True + user.role.save() + + # all sites should be returned + response = self.check_authorized("get", url) + self.assertEqual(len(response.data), 12) # type: ignore + + # limit user to specific site. only 1 site should be returned + user.role.can_view_sites.set([site]) + response = self.check_authorized("get", url) + self.assertEqual(len(response.data), 5) # type: ignore + + # all should be returned now + user.role.can_view_clients.set([other_site.client]) + response = self.check_authorized("get", url) + self.assertEqual(len(response.data), 12) # type: ignore + + # check if limiting user to client works + user.role.can_view_sites.clear() + user.role.can_view_clients.set([other_site.client]) + response = self.check_authorized("get", url) + self.assertEqual(len(response.data), 7) # type: ignore + + @patch("clients.models.Deployment.save") + def test_add_deployments_permissions(self, save): + site = baker.make("clients.Site") + unauthorized_site = baker.make("clients.Site") + data = { + "site": site.id, + } + + # test adding to unauthorized client + unauthorized_data = { + "site": unauthorized_site.id, + } + + url = f"{base_url}/deployments/" + + # test superuser access + self.check_authorized_superuser("post", url, data) + + user = self.create_user_with_roles([]) + self.client.force_authenticate(user=user) # type: ignore + + # test user without role + self.check_not_authorized("post", url, data) + + # add user to role and test + user.role.can_manage_deployments = True + user.role.save() + + self.check_authorized("post", url, data) + + # limit to client and test + user.role.can_view_clients.set([site.client]) + self.check_authorized("post", url, data) + self.check_not_authorized("post", url, unauthorized_data) + + # limit to site and test + user.role.can_view_clients.clear() + user.role.can_view_sites.set([site]) + self.check_authorized("post", url, data) + self.check_not_authorized("post", url, unauthorized_data) + + @patch("clients.models.Deployment.delete") + def test_delete_deployments_permissions(self, delete): + site = baker.make("clients.Site") + unauthorized_site = baker.make("clients.Site") + deployment = baker.make("clients.Deployment", site=site) + unauthorized_deployment = baker.make("clients.Deployment", site=unauthorized_site) + + url = f"{base_url}/deployments/{deployment.id}/" + unauthorized_url = f"{base_url}/deployments/{unauthorized_deployment.id}/" + + # make sure superusers work + self.check_authorized_superuser("delete", url) + self.check_authorized_superuser("delete", unauthorized_url) + + # create user with empty role + user = self.create_user_with_roles([]) + self.client.force_authenticate(user=user) # type: ignore + + # make sure user with empty role is unauthorized + self.check_not_authorized("delete", url) + self.check_not_authorized("delete", unauthorized_url) + + # add correct roles for view edit and delete + user.role.can_manage_deployments = True + user.role.save() + + self.check_authorized("delete", url) + self.check_authorized("delete", unauthorized_url) + + # test limiting users to clients and sites + + # limit to site + user.role.can_view_sites.set([site]) + + # recreate deployment since it is being deleted even though I am mocking delete on Deployment model??? + unauthorized_deployment = baker.make("clients.Deployment", site=unauthorized_site) + unauthorized_url = f"{base_url}/deployments/{unauthorized_deployment.id}/" + + self.check_authorized("delete", url) + self.check_not_authorized("delete", unauthorized_url) + + # test limit to only client + user.role.can_view_sites.clear() + user.role.can_view_clients.set([site.client]) + + self.check_authorized("delete", url) + self.check_not_authorized("delete", unauthorized_url) diff --git a/api/tacticalrmm/clients/views.py b/api/tacticalrmm/clients/views.py index c9236347..04a60aa3 100644 --- a/api/tacticalrmm/clients/views.py +++ b/api/tacticalrmm/clients/views.py @@ -8,10 +8,12 @@ from django.utils import timezone as djangotime from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView +from rest_framework.exceptions import PermissionDenied from agents.models import Agent from core.models import CoreSettings from tacticalrmm.utils import notify_error +from tacticalrmm.permissions import _has_perm_on_client, _has_perm_on_site from .models import Client, ClientCustomField, Deployment, Site, SiteCustomField from .permissions import ( @@ -143,6 +145,10 @@ class GetAddSites(APIView): return Response(SiteSerializer(sites, many=True).data) def post(self, request): + + if not _has_perm_on_client(request.user, request.data["site"]["client"]): + raise PermissionDenied() + serializer = SiteSerializer(data=request.data["site"]) serializer.is_valid(raise_exception=True) site = serializer.save() @@ -233,16 +239,18 @@ class AgentDeployment(APIView): permission_classes = [IsAuthenticated, DeploymentPerms] def get(self, request): - deps = Deployment.objects.all() + deps = Deployment.objects.filter_by_role(request.user) return Response(DeploymentSerializer(deps, many=True).data) def post(self, request): from knox.models import AuthToken from accounts.models import User - client = get_object_or_404(Client, pk=request.data["client"]) site = get_object_or_404(Site, pk=request.data["site"]) + if not _has_perm_on_site(request.user, site.pk): + raise PermissionDenied() + installer_user = User.objects.filter(is_installer_user=True).first() expires = dt.datetime.strptime( @@ -259,7 +267,6 @@ class AgentDeployment(APIView): } Deployment( - client=client, site=site, expiry=expires, mon_type=request.data["agenttype"], @@ -268,17 +275,21 @@ class AgentDeployment(APIView): token_key=token, install_flags=flags, ).save() - return Response("ok") + return Response("The deployment was added successfully") def delete(self, request, pk): d = get_object_or_404(Deployment, pk=pk) + + if not _has_perm_on_site(request.user, d.site.pk): + raise PermissionDenied() + try: d.auth_token.delete() except: pass d.delete() - return Response("ok") + return Response("The deployment was deleted") class GenerateAgent(APIView): diff --git a/api/tacticalrmm/tacticalrmm/models.py b/api/tacticalrmm/tacticalrmm/models.py index 4959745c..83dc09c6 100644 --- a/api/tacticalrmm/tacticalrmm/models.py +++ b/api/tacticalrmm/tacticalrmm/models.py @@ -19,7 +19,7 @@ class PermissionQuerySet(models.QuerySet): return self # checks which sites and clients the user has access to and filters agents - if model_name == "Agent": + if model_name in ["Agent", "Deployment"]: if can_view_clients: clients_queryset = models.Q(site__client__in=can_view_clients) diff --git a/web/src/api/clients.js b/web/src/api/clients.js index e502a8fc..faa9c6ef 100644 --- a/web/src/api/clients.js +++ b/web/src/api/clients.js @@ -2,6 +2,7 @@ import axios from "axios" const baseUrl = "/clients" +// client endpoints export async function fetchClients() { try { const { data } = await axios.get(`${baseUrl}/`) @@ -31,6 +32,7 @@ export async function removeClient(id, params = {}) { return data } +// site endpoints export async function fetchSites() { try { const { data } = await axios.get(`${baseUrl}/sites/`) @@ -59,3 +61,21 @@ export async function removeSite(id, params = {}) { const { data } = await axios.delete(`${baseUrl}/sites/${id}/`, { params: params }) return data } + +// deployment endpoints +export async function fetchDeployments() { + try { + const { data } = await axios.get(`${baseUrl}/deployments/`) + return data + } catch (e) { console.error(e) } +} + +export async function saveDeployment(payload) { + const { data } = await axios.post(`${baseUrl}/deployments/`, payload) + return data +} + +export async function removeDeployment(id, params = {}) { + const { data } = await axios.delete(`${baseUrl}/deployments/${id}/`, { params: params }) + return data +} \ No newline at end of file diff --git a/web/src/components/Deployment.vue b/web/src/components/Deployment.vue deleted file mode 100644 index 0bd71cde..00000000 --- a/web/src/components/Deployment.vue +++ /dev/null @@ -1,136 +0,0 @@ - - - \ No newline at end of file diff --git a/web/src/components/FileBar.vue b/web/src/components/FileBar.vue index adb3233c..3a4d64cd 100644 --- a/web/src/components/FileBar.vue +++ b/web/src/components/FileBar.vue @@ -48,7 +48,7 @@ Install Agent - + Manage Deployments @@ -163,10 +163,6 @@ - - - - @@ -195,7 +191,7 @@ import AdminManager from "@/components/AdminManager"; import InstallAgent from "@/components/modals/agents/InstallAgent"; import AuditManager from "@/components/logs/AuditManager"; import BulkAction from "@/components/modals/agents/BulkAction"; -import Deployment from "@/components/Deployment"; +import Deployment from "@/components/clients/Deployment"; import ServerMaintenance from "@/components/modals/core/ServerMaintenance"; import CodeSign from "@/components/modals/coresettings/CodeSign"; import PermissionsManager from "@/components/accounts/PermissionsManager"; @@ -208,7 +204,6 @@ export default { EditCoreSettings, InstallAgent, AdminManager, - Deployment, ServerMaintenance, CodeSign, PermissionsManager, @@ -220,7 +215,6 @@ export default { showEditCoreSettingsModal: false, showAdminManager: false, showInstallAgent: false, - showDeployment: false, showCodeSign: false, }; }, @@ -248,14 +242,6 @@ export default { } window.open(url, "_blank"); }, - showBulkActionModal(mode) { - this.bulkMode = mode; - this.showBulkAction = true; - }, - closeBulkActionModal() { - this.bulkMode = null; - this.showBulkAction = false; - }, showAutomationManager() { this.$q.dialog({ component: AutomationManager, @@ -338,6 +324,11 @@ export default { component: PendingActions, }); }, + showDeployments() { + this.$q.dialog({ + component: Deployment, + }); + }, edited() { this.$emit("edit"); }, diff --git a/web/src/components/clients/Deployment.vue b/web/src/components/clients/Deployment.vue new file mode 100644 index 00000000..6f5b760a --- /dev/null +++ b/web/src/components/clients/Deployment.vue @@ -0,0 +1,171 @@ + + + \ No newline at end of file diff --git a/web/src/components/clients/NewDeployment.vue b/web/src/components/clients/NewDeployment.vue new file mode 100644 index 00000000..0545ffa6 --- /dev/null +++ b/web/src/components/clients/NewDeployment.vue @@ -0,0 +1,132 @@ + + + \ No newline at end of file diff --git a/web/src/components/modals/clients/NewDeployment.vue b/web/src/components/modals/clients/NewDeployment.vue deleted file mode 100644 index 4f8261a5..00000000 --- a/web/src/components/modals/clients/NewDeployment.vue +++ /dev/null @@ -1,150 +0,0 @@ - - - \ No newline at end of file