diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 062131170..52bfa660b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,6 +72,8 @@ jobs: pip install -U pip wheel setuptools pip install -U -r requirements-test.txt pip install -U -e . + pip install --upgrade --force-reinstall --no-deps --no-cache-dir https://github.com/openwisp/openwisp-users/tarball/issues/522-disabled-org + pip install --upgrade --force-reinstall --no-deps --no-cache-dir https://github.com/openwisp/openwisp-ipam/tarball/fix-disabled-org-handling pip install ${{ matrix.django-version }} - name: Start redis diff --git a/AGENTS.md b/AGENTS.md index bae0ad9ee..9f626bd25 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,6 +78,8 @@ If instructions conflict (please let us know!), repository config and CI workflo - Watch for cross-organization data leaks, command execution issues, unsafe file paths, unsafe redirects, insecure credentials, and secrets. - Preserve validation around templates, VPN/PKI material, SSH credentials, device commands, uploaded files, URLs, and subnet/IP data. +- Objects belonging to a disabled organization must be readable and deletable; creation and updates must be blocked across all relevant write paths. This applies to objects with either a direct or chained/nested relationship to the organization. No other operations should be permitted, except for ordinary cleanup operations. +- Operations on deactivated devices must be blocked, except for read-only access and cleanup operations required to maintain consistency. Creation, updates, provisioning, configuration, and other mutating operations must not be performed for deactivated devices. ## Troubleshooting diff --git a/docs/user/device-config-status.rst b/docs/user/device-config-status.rst index a07fcbaca..82905c792 100644 --- a/docs/user/device-config-status.rst +++ b/docs/user/device-config-status.rst @@ -30,6 +30,8 @@ the device to revert to its previous working configuration. The device is in the process of being deactivated. The configuration is scheduled to be removed from the device. +.. _controller_deactivated_config_status: + ``deactivated`` --------------- diff --git a/openwisp_controller/config/admin.py b/openwisp_controller/config/admin.py index b82f65ece..ce2b73976 100644 --- a/openwisp_controller/config/admin.py +++ b/openwisp_controller/config/admin.py @@ -571,6 +571,7 @@ class DeviceAdmin(MultitenantAdminMixin, BaseConfigAdmin, CopyableFieldsAdmin): "activate_device", "delete_selected", ] + disabled_organization_action_exclusions = ("deactivate_device",) org_position = 1 if not app_settings.HARDWARE_ID_ENABLED else 2 list_display.insert(org_position, "organization") _state_adding = False @@ -601,7 +602,7 @@ def media(self): return super().media + forms.Media(js=js, css={"all": css}) def has_change_permission(self, request, obj=None): - perm = super().has_change_permission(request) + perm = super().has_change_permission(request, obj) if not obj or getattr(request, "_recover_view", False): return perm return perm and not obj.is_deactivated() @@ -726,8 +727,9 @@ def change_group(self, request, queryset): # Validate all selected devices belong to the same organization # which is managed by the user. org_id = None - if queryset: - org_id = queryset[0].organization_id + first_device = queryset.select_related("organization").first() + if first_device: + org_id = first_device.organization_id if not request.user.is_superuser and not request.user.is_manager(org_id): logger.warning(f'{request.user} does not manage "{org_id}" organization.') return HttpResponseForbidden() @@ -738,6 +740,13 @@ def change_group(self, request, queryset): messages.ERROR, ) return HttpResponseRedirect(request.get_full_path()) + if first_device and not first_device.organization.is_active: + self.message_user( + request, + _("Selected organization is disabled."), + messages.ERROR, + ) + return HttpResponseRedirect(request.get_full_path()) if "apply" in request.POST: form = ChangeDeviceGroupForm(data=request.POST, org_id=org_id) @@ -868,7 +877,24 @@ def deactivate_device(self, request, queryset): @admin.action(description=_("Activate selected devices"), permissions=["change"]) def activate_device(self, request, queryset): - self._change_device_status(request, queryset, "activate") + disabled_org_devices = list(queryset.filter(organization__is_active=False)) + if disabled_org_devices: + devices_html = ", ".join( + self._get_device_path(device) for device in disabled_org_devices + ) + self.message_user( + request, + mark_safe( + _("Cannot activate devices of a disabled organization: %(devices)s") + % {"devices": devices_html} + ), + messages.ERROR, + ) + self._change_device_status( + request, + queryset.filter(organization__is_active=True), + "activate", + ) @admin.action(description=delete_selected.short_description, permissions=["delete"]) def delete_selected(self, request, queryset): @@ -976,15 +1002,18 @@ def get_urls(self): def get_extra_context(self, pk=None): ctx = super().get_extra_context(pk) if pk: - device = self.model.objects.select_related("config").get(id=pk) + device = self.model.objects.select_related("config", "organization").get( + id=pk + ) ctx.update( { "show_deactivate": not device.is_deactivated(), - "show_activate": device.is_deactivated(), + "show_activate": device.is_deactivated() + and device.organization.is_active, "action_checkbox_name": helpers.ACTION_CHECKBOX_NAME, } ) - if device.is_deactivated(): + if ctx["show_activate"]: ctx["additional_buttons"].append( { "raw_html": mark_safe( @@ -993,7 +1022,7 @@ def get_extra_context(self, pk=None): ) } ) - else: + elif not device.is_deactivated(): ctx["additional_buttons"].append( { "raw_html": mark_safe( @@ -1178,10 +1207,19 @@ def save_clones(view, user, queryset, organization=None): # validate organization if organization: try: - validated_org = Organization.objects.get(pk=organization) + validated_org = Organization.active.get(pk=organization) except (ValidationError, Organization.DoesNotExist) as e: logger.warning( - f"Detected tampering in clone template form by user {user}: {e}" + "Cannot clone template: the organization selected by " + f"user {user} does not exist or is disabled: {e}" + ) + view.message_user( + request, + _( + "Cannot clone templates: the selected organization" + " does not exist or is disabled." + ), + messages.ERROR, ) return if not user.is_superuser and not user.is_manager(organization): @@ -1236,11 +1274,11 @@ def save_clones(view, user, queryset, organization=None): ) if user.is_superuser: - all_orgs = Organization.objects.all() + all_orgs = Organization.active.all() if all_orgs.count() > 1: selectable_orgs = all_orgs elif len(user.organizations_managed) > 1: - selectable_orgs = Organization.objects.filter( + selectable_orgs = Organization.active.filter( pk__in=user.organizations_managed ) if selectable_orgs: diff --git a/openwisp_controller/config/api/serializers.py b/openwisp_controller/config/api/serializers.py index d74ca28b9..5108f22a3 100644 --- a/openwisp_controller/config/api/serializers.py +++ b/openwisp_controller/config/api/serializers.py @@ -112,10 +112,15 @@ class FilterTemplatesByOrganization(serializers.PrimaryKeyRelatedField): def get_queryset(self): user = self.context["request"].user if user.is_superuser: - queryset = Template.objects.all() + queryset = Template.objects.filter( + Q(organization__is_active=True) | Q(organization__isnull=True) + ) else: queryset = Template.objects.filter( - Q(organization__in=user.organizations_managed) + Q( + organization__in=user.organizations_managed, + organization__is_active=True, + ) | Q(organization__isnull=True) ) return queryset diff --git a/openwisp_controller/config/api/views.py b/openwisp_controller/config/api/views.py index 5d6858b6a..0b26d1856 100644 --- a/openwisp_controller/config/api/views.py +++ b/openwisp_controller/config/api/views.py @@ -3,6 +3,7 @@ from django.db.models import F, Q from django.http import Http404 from django.urls.base import reverse +from django.utils.translation import gettext_lazy as _ from django_filters.rest_framework import DjangoFilterBackend from rest_framework import serializers, status from rest_framework.generics import ( @@ -98,7 +99,7 @@ class DeviceDetailView(ProtectedAPIMixin, RetrieveUpdateDestroyAPIView): """ serializer_class = DeviceDetailSerializer - queryset = Device.objects.select_related("config", "group", "organization") + queryset = Device.objects.select_related("config", "group") permission_classes = ProtectedAPIMixin.permission_classes + (DevicePermission,) def perform_destroy(self, instance): @@ -124,6 +125,11 @@ class DeviceActivateView(ProtectedAPIMixin, GenericAPIView): def post(self, request, *args, **kwargs): device = self.get_object() + if not device.organization.is_active: + return Response( + {"detail": _("Cannot activate a device of a disabled organization.")}, + status=status.HTTP_403_FORBIDDEN, + ) device.activate() serializer = DeviceDetailSerializer( device, context=self.get_serializer_context() @@ -134,6 +140,10 @@ def post(self, request, *args, **kwargs): class DeviceDeactivateView(ProtectedAPIMixin, GenericAPIView): serializer_class = serializers.Serializer queryset = Device.objects.filter(_is_deactivated=False) + # Deactivation stays allowed even when the organization is disabled: + # it's the remediation an operator needs if the org-wide deactivation + # task failed for this device (see deactivate_organization_devices). + allow_disabled_organization_writes = True def post(self, request, *args, **kwargs): device = self.get_object() @@ -154,7 +164,7 @@ class DeviceGroupListCreateView(ProtectedAPIMixin, ListCreateAPIView): class DeviceGroupDetailView(ProtectedAPIMixin, RetrieveUpdateDestroyAPIView): serializer_class = DeviceGroupSerializer - queryset = DeviceGroup.objects.select_related("organization").order_by("-created") + queryset = DeviceGroup.objects.order_by("-created") def get_cached_devicegroup_args_rewrite(cls, org_slugs, common_name): @@ -168,7 +178,7 @@ def get_cached_devicegroup_args_rewrite(cls, org_slugs, common_name): class DeviceGroupCommonName(ProtectedAPIMixin, RetrieveAPIView): serializer_class = DeviceGroupSerializer - queryset = DeviceGroup.objects.select_related("organization").order_by("-created") + queryset = DeviceGroup.objects.order_by("-created") # Not setting lookup_field makes DRF raise error. but it is not used lookup_field = "pk" @@ -190,7 +200,7 @@ def get_device_group(cls, org_slugs, common_name): ) vpnclient = VpnClient.objects.only("config_id").get(cert_id=cert.id) group = ( - Device.objects.select_related("group") + Device.objects.select_related("group", "group__organization") .only("group") .get(config=vpnclient.config_id) .group diff --git a/openwisp_controller/config/apps.py b/openwisp_controller/config/apps.py index 032321db9..fc9083b2e 100644 --- a/openwisp_controller/config/apps.py +++ b/openwisp_controller/config/apps.py @@ -11,6 +11,7 @@ ) from swapper import get_model_name, load_model +from openwisp_users.signals import organization_disabled from openwisp_utils.admin_theme import register_dashboard_chart from openwisp_utils.admin_theme.menu import register_menu_group @@ -105,11 +106,11 @@ def connect_cache_dependencies(self): DeviceChecksumView.invalidate_get_device_cache_on_config_deactivated ), ), - # When an organization is disabled, all its devices are deactivated, - # so we need to invalidate the controller view caches for all objects. CacheDependency( - source=self.org_model, - signal="pre_save", + signal_obj=organization_disabled, + name="organization_disabled", + # organization_disabled signal is fired when the transaction + # is committed to the database. on_commit=False, target=organization_disabled_handler, ), diff --git a/openwisp_controller/config/base/config.py b/openwisp_controller/config/base/config.py index 10c2260aa..82c3e5e25 100644 --- a/openwisp_controller/config/base/config.py +++ b/openwisp_controller/config/base/config.py @@ -486,6 +486,10 @@ def manage_vpn_clients(cls, action, instance, pk_set, **kwargs): ).delete() if action == "post_add": + if not instance.device.organization.is_active: + # Do not create VPN clients (and their certs) for a + # disabled organization + return # A single template change can trigger multiple m2m_changed events. # Use the full current template set instead of this event's pk_set so # every attached VPN template has its VpnClient. diff --git a/openwisp_controller/config/base/device.py b/openwisp_controller/config/base/device.py index 626d5bb51..8fd4730e8 100644 --- a/openwisp_controller/config/base/device.py +++ b/openwisp_controller/config/base/device.py @@ -485,9 +485,10 @@ def create_default_config(self, **options): creates a new config instance to apply group templates if group has templates. """ - if self.is_deactivated(): - # All modification operations are blocked on deactivated devices. - # Hence, default config should not be created for deactivated devices. + if self.is_deactivated() or not self.organization.is_active: + # All modification operations are blocked on deactivated devices + # and on devices belonging to a disabled organization. Hence, + # default config should not be created in either case. return if not (self.group and self.group.templates.exists()): return @@ -508,11 +509,12 @@ def manage_devices_group_templates(cls, device_ids, old_group_ids, group_id): device_ids = [device_ids] old_group_ids = [old_group_ids] for device_id, old_group_id in zip(device_ids, old_group_ids): - device = Device.objects.get(pk=device_id) - if device.is_deactivated(): + device = Device.objects.select_related("organization").get(pk=device_id) + if device.is_deactivated() or not device.organization.is_active: # Skip deactivated devices: their configuration is intentionally # emptied during deactivation, so re-applying group templates # would break that state and trigger a push to the device. + # Also skip devices of a disabled organization. continue if not hasattr(device, "config"): device.create_default_config() diff --git a/openwisp_controller/config/base/device_group.py b/openwisp_controller/config/base/device_group.py index 5500e265a..111dfc880 100644 --- a/openwisp_controller/config/base/device_group.py +++ b/openwisp_controller/config/base/device_group.py @@ -94,6 +94,12 @@ def manage_group_templates(cls, group_id, old_template_ids, template_ids): DeviceGroup = load_model("config", "DeviceGroup") Template = load_model("config", "Template") device_group = DeviceGroup.objects.get(id=group_id) + if not device_group.organization.is_active: + # Do not push template changes to a disabled organization's + # devices; closes the race window between an organization + # being disabled and the async deactivate_organization_devices + # task deactivating each of its devices. + return templates = Template.objects.filter(pk__in=template_ids) old_templates = Template.objects.filter(pk__in=old_template_ids) for device in device_group.device_set.exclude(_is_deactivated=True).iterator(): diff --git a/openwisp_controller/config/base/vpn.py b/openwisp_controller/config/base/vpn.py index 61de40a66..52b11b868 100644 --- a/openwisp_controller/config/base/vpn.py +++ b/openwisp_controller/config/base/vpn.py @@ -252,6 +252,8 @@ def save(self, *args, **kwargs): """ Calls _auto_create_cert() if cert is not set. """ + if self.organization_id and not self.organization.is_active: + return super().save(*args, **kwargs) config = {} created = self._state.adding if not created: @@ -958,6 +960,8 @@ def _get_unique_checks(self, exclude=None, include_meta_constraints=False): def save(self, *args, **kwargs): """Performs automatic provisioning if ``auto_cert`` is True.""" + if not self.config.device.organization.is_active: + return super().save(*args, **kwargs) if self.auto_cert: self._auto_x509() self._auto_ip() @@ -970,7 +974,11 @@ def _auto_x509(self): """ Automatically creates an x509 certificate. """ - if not self.vpn._is_backend_type("openvpn") or self.cert: + if ( + not self.vpn._is_backend_type("openvpn") + or self.cert + or not self.config.device.organization.is_active + ): return cn = self._get_common_name() self._auto_create_cert(name=self.config.device.name, common_name=cn) diff --git a/openwisp_controller/config/controller/views.py b/openwisp_controller/config/controller/views.py index 2a852e3d2..c5088259a 100644 --- a/openwisp_controller/config/controller/views.py +++ b/openwisp_controller/config/controller/views.py @@ -44,7 +44,7 @@ class GetDeviceView(SingleObjectMixin, View): model = Device def get_object(self, *args, **kwargs): - kwargs.update({"organization__is_active": True, "config__isnull": False}) + kwargs.update({"config__isnull": False}) defer = ( "notes", "organization__name", @@ -57,6 +57,7 @@ def get_object(self, *args, **kwargs): queryset = ( self.model.objects.select_related("organization", "config") .defer(*defer) + .filter(Q(organization__is_active=True) | Q(config__status="deactivating")) .exclude(config__status="deactivated") ) return get_object_or_404(queryset, *args, **kwargs) @@ -116,7 +117,12 @@ def _remove_duplicated_last_ip(self, device): # dupe.save() triggers signal handlers that call is_deactivated() # and WHOIS checks that read device.organization. for dupe in queryset.select_related("organization").only( - "pk", "key", "last_ip", "_is_deactivated", "organization__id" + "pk", + "key", + "last_ip", + "_is_deactivated", + "organization__id", + "organization__is_active", ): dupe.last_ip = "" dupe.save(update_fields=["last_ip"]) @@ -414,9 +420,19 @@ def post(self, request, *args, **kwargs): # (key is not None only if CONSISTENT_REGISTRATION is enabled) new = False try: - device = self.model.objects.select_related("config").get(key=key) + device = self.model.objects.select_related("config", "organization").get( + key=key + ) if device.is_deactivated(): return ControllerResponse("error: device deactivated", status=403) + if device.organization_id != self.organization.id: + # The shared secret matched a different (active) organization + # than the one this device actually belongs to; treat it the + # same as an unrecognized secret rather than leaking that the + # device exists. + return ControllerResponse("error: unrecognized secret", status=403) + if not device.organization.is_active: + return ControllerResponse("error: organization disabled", status=403) # update device info for attr in self.UPDATABLE_FIELDS: if attr in request.POST: diff --git a/openwisp_controller/config/exportable.py b/openwisp_controller/config/exportable.py index 37fa46ccc..df7d3c4c9 100644 --- a/openwisp_controller/config/exportable.py +++ b/openwisp_controller/config/exportable.py @@ -1,7 +1,8 @@ import json import uuid -from django.core.exceptions import ObjectDoesNotExist +from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.utils.translation import gettext_lazy as _ from import_export import resources, widgets from import_export.fields import Field from swapper import load_model @@ -11,6 +12,7 @@ Device = load_model("config", "Device") Config = load_model("config", "Config") Template = load_model("config", "Template") +Organization = load_model("openwisp_users", "Organization") class ManyToManyWidget(widgets.ManyToManyWidget): @@ -120,6 +122,16 @@ def validate_instance( super().validate_instance( instance, import_validation_errors=None, validate_unique=True ) + if ( + instance.organization_id + and not Organization.objects.filter( + pk=instance.organization_id, + is_active=True, + ).exists() + ): + raise ValidationError( + {"organization_id": _("Cannot import rows for disabled organizations.")} + ) if not instance._has_config(): return config = instance.config diff --git a/openwisp_controller/config/handlers.py b/openwisp_controller/config/handlers.py index 1443d0016..68450bad2 100644 --- a/openwisp_controller/config/handlers.py +++ b/openwisp_controller/config/handlers.py @@ -1,3 +1,4 @@ +from celery import chain from django.db import transaction from django.dispatch import receiver from django.utils.translation import gettext_lazy as _ @@ -10,7 +11,6 @@ Config = load_model("config", "Config") Device = load_model("config", "Device") DeviceGroup = load_model("config", "DeviceGroup") -Organization = load_model("openwisp_users", "Organization") Cert = load_model("django_x509", "Cert") @@ -189,15 +189,11 @@ def devicegroup_templates_change_handler(instance, **kwargs): def organization_disabled_handler(instance, **kwargs): """ - Asynchronously invalidates device and VPN controller views cache + Deactivates devices and invalidates controller view caches when + openwisp-users signals that an organization has been disabled. """ - if instance.is_active: - return - try: - db_instance = Organization.objects.only("is_active").get(id=instance.id) - except Organization.DoesNotExist: - return - if instance.is_active == db_instance.is_active: - # No change in is_active - return - tasks.invalidate_controller_views_cache.delay(str(instance.id)) + organization_id = str(instance.pk) + chain( + tasks.deactivate_organization_devices.s(organization_id), + tasks.invalidate_controller_views_cache.si(organization_id), + ).delay() diff --git a/openwisp_controller/config/tasks.py b/openwisp_controller/config/tasks.py index abf51b310..931da7ba3 100644 --- a/openwisp_controller/config/tasks.py +++ b/openwisp_controller/config/tasks.py @@ -130,7 +130,10 @@ def trigger_vpn_server_endpoint(endpoint, auth_token, vpn_id): except Vpn.DoesNotExist: logger.error(f"VPN Server UUID: {vpn_id} does not exist.") return - + # Do not skip disabled organizations: the update is still needed to push + # peer removals caused by cascading device deactivation. Peer additions are + # already blocked upstream because disabled organizations cannot create new + # devices or VPN clients. # Cache the configuration here makes downloading the configuration faster. vpn.get_cached_configuration() task_key = f"vpn_update_task:{vpn_id}" @@ -218,3 +221,30 @@ def invalidate_controller_views_cache(organization_id): Vpn.objects.filter(organization_id=organization_id).only("id").iterator() ): GetVpnView.invalidate_get_vpn_cache(vpn) + + +@shared_task(base=OpenwispCeleryTask) +def deactivate_organization_devices(organization_id): + """ + Deactivate all active devices of an organization. + + Re-enabling an organization does not reactivate its devices. + """ + Device = load_model("config", "Device") + devices = ( + Device.objects.filter( + organization_id=organization_id, + _is_deactivated=False, + ) + .select_related("config") + .order_by("created") + ) + for device in devices.iterator(): + try: + device.deactivate() + except Exception: + logger.exception( + "Failed to deactivate device %s while disabling organization %s", + device.pk, + organization_id, + ) diff --git a/openwisp_controller/config/tests/test_admin.py b/openwisp_controller/config/tests/test_admin.py index d6c24e299..784e24106 100644 --- a/openwisp_controller/config/tests/test_admin.py +++ b/openwisp_controller/config/tests/test_admin.py @@ -7,7 +7,7 @@ from uuid import uuid4 import django -from django.contrib import admin +from django.contrib import admin as django_admin from django.contrib.admin.models import LogEntry from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError @@ -56,6 +56,7 @@ Location = load_model("geo", "Location") DeviceLocation = load_model("geo", "DeviceLocation") Group = load_model("openwisp_users", "Group") +Organization = load_model("openwisp_users", "Organization") class TestImportExportMixin: @@ -119,6 +120,25 @@ def test_device_import(self): self.assertNotContains(response, "Errors") self.assertContains(response, "Confirm import") + def test_device_import_disabled_organization_rejected(self): + org = self._get_org() + org.is_active = False + org.save(update_fields=["is_active"]) + contents = ( + "organization_id,name,mac_address\n" + f"{org.pk},TestImportDisabled,00:11:22:09:44:55" + ) + csv = ContentFile(contents) + response = self.client.post( + reverse(f"admin:{self.app_label}_device_import"), + {"format": "0", "import_file": csv}, + ) + self.assertNotIn("confirm_form", response.context) + self.assertContains(response, "Cannot import rows for disabled organizations.") + self.assertEqual( + Device.objects.filter(name="TestImportDisabled").exists(), False + ) + def test_device_import_empty_config(self): org = self._get_org(org_name="default") contents = ( @@ -264,6 +284,56 @@ def tearDownClass(cls): super().tearDownClass() devnull.close() + def test_disabled_organization_inlines_write_protected(self): + # When an organization is disabled, the inlines that openwisp-controller + # attaches to the organization admin (configuration settings, shared + # object limits, and geographic settings) must become read-only. The + # guard is inherited from openwisp-users' OrganizationAdmin, so this + # verifies the downstream inlines are covered without re-implementing + # it. The guard only denies add and change; it leaves each inline's own + # delete decision alone, so inlines that normally allow deletion keep + # allowing it (deletion of a disabled organization's data stays + # possible). + request = RequestFactory().get("/") + request.user = self._get_admin() + org_admin = django_admin.site._registry[Organization] + disabled_org = self._create_org(name="disabled-inline-org", is_active=False) + inlines = org_admin.get_inline_instances(request, disabled_org) + inlines_by_name = {type(inline).__name__: inline for inline in inlines} + for expected in ( + "ConfigSettingsInline", + "OrganizationLimitsInline", + "GeoSettingsInline", + ): + self.assertIn(expected, inlines_by_name) + for name, inline in inlines_by_name.items(): + with self.subTest(inline=name): + self.assertEqual( + inline.has_add_permission(request, disabled_org), False + ) + self.assertEqual( + inline.has_change_permission(request, disabled_org), False + ) + # inlines that normally permit deletion still do, proving the guard did + # not block deleting the disabled organization's related objects + for name in ("ConfigSettingsInline", "GeoSettingsInline"): + with self.subTest(inline=name): + self.assertEqual( + inlines_by_name[name].has_delete_permission(request, disabled_org), + True, + ) + + def test_active_organization_inlines_writable(self): + request = RequestFactory().get("/") + request.user = self._get_admin() + org_admin = django_admin.site._registry[Organization] + active_org = self._create_org(name="active-inline-org") + for inline in org_admin.get_inline_instances(request, active_org): + with self.subTest(inline=type(inline).__name__): + self.assertEqual( + inline.has_change_permission(request, active_org), True + ) + def test_device_and_template_different_organization(self): org1 = self._get_org() template = self._create_template(organization=org1) @@ -492,6 +562,7 @@ def test_template_vpn_fk_autocomplete_view(self): url=self._get_autocomplete_view_path(self.app_label, "template", "vpn"), visible=[data["vpn1"].name], hidden=[data["vpn2"].name, data["vpn_inactive"].name], + superuser_hidden=[data["vpn_inactive"].name], ) def test_vpn_queryset(self): @@ -525,14 +596,16 @@ def test_vpn_ca_fk_queryset(self): hidden=[data["vpn2"].ca.name, data["vpn_inactive"].ca.name], select_widget=True, administrator=True, + superuser_hidden=[data["vpn_inactive"].ca.name], ) def test_vpn_cert_fk_queryset(self): data = self._create_multitenancy_test_env(vpn=True) + self.assertIsNone(data["vpn_inactive"].cert) self._test_multitenant_admin( url=reverse(f"admin:{self.app_label}_vpn_add"), visible=[data["vpn1"].cert.name, data["vpn_shared"].cert.name], - hidden=[data["vpn2"].cert.name, data["vpn_inactive"].cert.name], + hidden=[data["vpn2"].cert.name], select_widget=True, administrator=True, ) @@ -666,6 +739,64 @@ def test_change_group_action_skips_deactivated_device(self): device.refresh_from_db() self.assertIsNone(device.group) + def test_change_group_action_disabled_org(self): + path = reverse(f"admin:{self.app_label}_device_changelist") + org = self._get_org(org_name="default") + org.is_active = False + org.save(update_fields=["is_active"]) + group = self._create_device_group(name="test-group", organization=org) + device = self._create_device(organization=org) + post_data = { + "_selected_action": [device.pk], + "action": "change_group", + "csrfmiddlewaretoken": "test", + "apply": True, + "device_group": group.pk, + } + response = self.client.post(path, post_data, follow=True) + self.assertEqual(response.status_code, 200) + self.assertContains( + response, "Actions cannot modify objects of disabled organizations." + ) + device.refresh_from_db() + self.assertIsNone(device.group) + + def test_activate_device_action_disabled_org(self): + org = self._get_org() + org.is_active = False + org.save(update_fields=["is_active"]) + device = self._create_device(organization=org, _is_deactivated=True) + path = reverse(f"admin:{self.app_label}_device_changelist") + data = { + "_selected_action": [device.pk], + "action": "activate_device", + "csrfmiddlewaretoken": "test", + } + response = self.client.post(path, data, follow=True) + self.assertEqual(response.status_code, 200) + self.assertContains( + response, "Actions cannot modify objects of disabled organizations." + ) + device.refresh_from_db() + self.assertEqual(device.is_deactivated(), True) + + def test_deactivate_device_action_disabled_org(self): + org = self._get_org() + org.is_active = False + org.save(update_fields=["is_active"]) + device = self._create_device(organization=org) + path = reverse(f"admin:{self.app_label}_device_changelist") + data = { + "_selected_action": [device.pk], + "action": "deactivate_device", + "csrfmiddlewaretoken": "test", + } + response = self.client.post(path, data, follow=True) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "was deactivated successfully") + device.refresh_from_db() + self.assertEqual(device.is_deactivated(), True) + def test_device_import_with_group_apply_templates(self): org = self._get_org(org_name="default") template = self._create_template(name="template") @@ -945,6 +1076,141 @@ def test_clone_templates_only_managed_orgs(self): self.assertNotContains(response, "

Clone templates

", html=True) self.assertEqual(Template.objects.count(), count + 1) + def test_clone_templates_disabled_target_org(self): + path = reverse(f"admin:{self.app_label}_template_changelist") + template = self._create_template(organization=self._get_org()) + disabled_org = self._create_org(name="disabled-org", is_active=False) + self._get_org("org_2") + post_data = self._get_clone_template_post_data(template) + post_data["organization"] = str(disabled_org.id) + count = Template.objects.count() + response = self.client.post(path, post_data, follow=True) + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, "Successfully cloned selected templates") + self.assertContains( + response, + "Cannot clone templates: the selected organization does not exist" + " or is disabled.", + ) + self.assertEqual(Template.objects.count(), count) + + def test_device_disabled_org_admin_crud(self): + org = self._create_org(name="disabled-org", slug="disabled-org") + device = self._create_device(organization=org, name="disabled-device") + org.is_active = False + org.save(update_fields=["is_active"]) + self._test_disabled_org_admin_crud( + device, + change_data={"name": "renamed-device"}, + operations=("view", "change", "add"), + create_data={ + "name": "new-device", + "organization": str(org.pk), + "mac_address": "00:11:22:33:44:66", + "key": "w1gwJxKaHcamUw62TQIPgYchwLKn3AA0", + "model": "", + "os": "", + "system": "", + "notes": "", + "management_ip": "127.0.0.1", + "hardware_id": "new-device-hardware-id", + }, + ) + with self.subTest("superuser delete, once deactivated"): + # DeviceAdmin.has_delete_permission additionally requires the + # device to be deactivated first, a precondition unrelated to + # the disabled-org policy; satisfy it here to isolate and + # confirm that a disabled organization's device stays deletable. + device._is_deactivated = True + device.save(update_fields=["_is_deactivated"]) + urls = self._get_disabled_org_admin_urls(device) + self.client.force_login(self._get_admin()) + self._test_disabled_org_admin_delete(urls["delete"], Device, device.pk) + self.client.logout() + + def test_devicegroup_disabled_org_admin_crud(self): + org = self._create_org(name="disabled-org", slug="disabled-org") + device_group = self._create_device_group( + name="disabled-group", organization=org + ) + org.is_active = False + org.save(update_fields=["is_active"]) + self._test_disabled_org_admin_crud( + device_group, + change_data={"name": "renamed-group"}, + create_data={ + "name": "new-device-group", + "organization": str(org.pk), + "description": "", + "context": "{}", + "meta_data": "{}", + }, + ) + + def test_template_disabled_org_admin_crud(self): + org = self._create_org(name="disabled-org", slug="disabled-org") + template = self._create_template(name="disabled-template", organization=org) + org.is_active = False + org.save(update_fields=["is_active"]) + self._test_disabled_org_admin_crud( + template, + change_data={"name": "renamed-template"}, + create_data={ + "name": "new-template", + "organization": str(org.pk), + "backend": "netjsonconfig.OpenWrt", + "config": "{}", + }, + ) + + def test_vpn_disabled_org_admin_crud(self): + org = self._create_org(name="disabled-org", slug="disabled-org") + vpn = self._create_vpn(name="disabled-vpn", organization=org) + org.is_active = False + org.save(update_fields=["is_active"]) + self._test_disabled_org_admin_crud( + vpn, + change_data={"name": "renamed-vpn"}, + create_data={ + "name": "new-vpn", + "organization": str(org.pk), + "host": "vpn2.test.com", + "backend": "openwisp_controller.vpn_backends.OpenVpn", + "config": "{}", + "dh": vpn.dh, + "ca": str(vpn.ca.pk), + }, + ) + + def test_device_disabled_org_admin_inline_readonly(self): + org = self._create_org(name="disabled-org", slug="disabled-org") + device = self._create_device(organization=org, name="disabled-device") + active_device = self._create_device( + name="active-device", mac_address="00:11:22:09:44:71" + ) + org.is_active = False + org.save(update_fields=["is_active"]) + model_admin = django_admin.site._registry[Device] + self._test_disabled_org_admin_inline_readonly( + model_admin, device, active_obj=active_device + ) + + def test_device_disabled_org_admin_org_field_excludes_disabled(self): + active_org = self._get_org(org_name="default") + disabled_org = self._create_org(name="disabled-org", is_active=False) + add_url = reverse(f"admin:{self.app_label}_device_add") + self._test_disabled_org_admin_org_field_excludes_disabled( + add_url, disabled_org, organization=active_org + ) + + def test_template_disabled_org_admin_org_field_excludes_disabled(self): + active_org = self._get_org(org_name="default") + disabled_org = self._create_org(name="disabled-org", is_active=False) + add_url = reverse(f"admin:{self.app_label}_template_add") + self._test_disabled_org_admin_org_field_excludes_disabled( + add_url, disabled_org, organization=active_org + ) + def test_clone_templates_validation_error(self): path = reverse(f"admin:{self.app_label}_template_changelist") # very long name will trigger validation error @@ -2289,7 +2555,7 @@ def _verify_template_queries(self, config, count): path = reverse(f"admin:{self.app_label}_device_change", args=[config.device.pk]) for i in range(count): self._create_template(name=f"template-{i}") - expected_count = 22 + expected_count = 23 if django.VERSION < (5, 2): # In django version < 5.2, there is an extra SAVEPOINT query # leading to extra RELEASE SAVEPOINT query, thus 2 extra queries @@ -2920,9 +3186,11 @@ def test_organization_delete_warning_uses_authorized_organizations(self): request.resolver_match = resolve(url) request.user = user # TODO: replace _registry with get_model_admin once Django 4.2 is dropped - device_admin = admin.site._registry[Device] + device_admin = django_admin.site._registry[Device] admin_site = SimpleNamespace( - _registry={first_org.__class__: admin.site._registry[first_org.__class__]} + _registry={ + first_org.__class__: django_admin.site._registry[first_org.__class__] + } ) with ( patch.object(device_admin, "admin_site", admin_site), @@ -2950,7 +3218,7 @@ def test_organization_delete_warning_is_checked_once(self): request = RequestFactory().get(url) request.resolver_match = resolve(url) # TODO: replace _registry with get_model_admin once Django 4.2 is dropped - device_admin = admin.site._registry[Device] + device_admin = django_admin.site._registry[Device] with patch.object( Device.objects, "filter", wraps=Device.objects.filter ) as filter_: diff --git a/openwisp_controller/config/tests/test_api.py b/openwisp_controller/config/tests/test_api.py index e5f2136a1..ddc0e86c5 100644 --- a/openwisp_controller/config/tests/test_api.py +++ b/openwisp_controller/config/tests/test_api.py @@ -9,11 +9,15 @@ from django.test.testcases import TransactionTestCase from django.urls import reverse from django.utils import timezone +from rest_framework.test import APIRequestFactory from swapper import load_model -from openwisp_controller.config.api.serializers import BaseConfigSerializer +from openwisp_controller.config.api.serializers import ( + BaseConfigSerializer, + FilterTemplatesByOrganization, +) from openwisp_controller.tests.utils import TestAdminMixin -from openwisp_users.tests.test_api import AuthenticationMixin +from openwisp_users.tests.test_api import AuthenticationMixin, TestDisabledOrgApiMixin from openwisp_utils.tests import capture_any_output, catch_signal from .. import settings as app_settings @@ -105,6 +109,7 @@ class TestConfigApi( CreateConfigTemplateMixin, TestVpnX509Mixin, CreateDeviceGroupMixin, + TestDisabledOrgApiMixin, AuthenticationMixin, TestCase, ): @@ -235,6 +240,23 @@ def execute_assertions(data): data["config"].update({"context": {}, "config": {}}) execute_assertions(data) + def test_filter_templates_by_organization_excludes_disabled_org(self): + disabled_org = self._create_org(name="disabled-org", is_active=False) + disabled_template = self._create_template( + name="disabled-template", organization=disabled_org + ) + shared_template = self._create_template( + name="shared-template", organization=None + ) + admin = self._get_admin() + request = APIRequestFactory().get("/") + request.user = admin + field = FilterTemplatesByOrganization() + field._context = {"request": request} + queryset = field.get_queryset() + self.assertNotIn(disabled_template, queryset) + self.assertIn(shared_template, queryset) + def test_device_create_with_devicegroup(self): self.assertEqual(Device.objects.count(), 0) path = reverse("config_api:device_list") @@ -251,9 +273,10 @@ def test_device_create_with_devicegroup(self): def test_device_list_api(self): device = self._create_device() path = reverse("config_api:device_list") - with patch.object( - app_settings, "WHOIS_CONFIGURED", False - ), self.assertNumQueries(3): + with ( + patch.object(app_settings, "WHOIS_CONFIGURED", False), + self.assertNumQueries(3), + ): r = self.client.get(path) self.assertEqual(r.status_code, 200) with self.subTest("device list should show most recent first"): @@ -399,9 +422,10 @@ def test_device_filter_templates(self): def test_device_detail_api(self): d1 = self._create_device() path = reverse("config_api:device_detail", args=[d1.pk]) - with patch.object( - app_settings, "WHOIS_CONFIGURED", False - ), self.assertNumQueries(2): + with ( + patch.object(app_settings, "WHOIS_CONFIGURED", False), + self.assertNumQueries(2), + ): r = self.client.get(path) self.assertEqual(r.status_code, 200) self.assertEqual(r.data["config"], None) @@ -411,9 +435,10 @@ def test_device_detail_config_api(self): d1 = self._create_device() self._create_config(device=d1) path = reverse("config_api:device_detail", args=[d1.pk]) - with patch.object( - app_settings, "WHOIS_CONFIGURED", False - ), self.assertNumQueries(3): + with ( + patch.object(app_settings, "WHOIS_CONFIGURED", False), + self.assertNumQueries(3), + ): r = self.client.get(path) self.assertEqual(r.status_code, 200) self.assertNotEqual(r.data["config"], None) @@ -586,6 +611,111 @@ def test_device_activate_api(self): device.refresh_from_db() self.assertEqual(device.is_deactivated(), False) + def test_device_activate_deactivate_api_disabled_org(self): + org = self._get_org() + org.is_active = False + org.save(update_fields=["is_active"]) + deactivated_device = self._create_device( + name="deactivated", + organization=org, + mac_address="00:11:22:09:44:55", + _is_deactivated=True, + ) + active_device = self._create_device( + name="active", organization=org, mac_address="00:11:22:09:44:56" + ) + activate_path = reverse( + "config_api:device_activate", args=[deactivated_device.pk] + ) + deactivate_path = reverse( + "config_api:device_deactivate", args=[active_device.pk] + ) + # Reactivation is blocked while the organization is disabled, but + # deactivation remains allowed so operators can remediate devices missed + # by the organization-wide deactivation task. + activate_response = self.client.post(activate_path) + deactivate_response = self.client.post(deactivate_path) + self.assertEqual(activate_response.status_code, 403) + self.assertEqual(deactivate_response.status_code, 200) + + def test_device_disabled_org_api_crud(self): + org = self._create_org(name="disabled-org", slug="disabled-org") + device = self._create_device(organization=org, name="disabled-device") + org.is_active = False + org.save(update_fields=["is_active"]) + self._test_disabled_org_api_crud( + device, + detail_url=reverse("config_api:device_detail", args=[device.pk]), + list_url=reverse("config_api:device_list"), + create_payload={ + **self._device_data, + "organization": str(org.pk), + "mac_address": "00:11:22:09:44:70", + }, + update_payload={"name": "renamed-device"}, + operations=("list", "retrieve", "create", "update"), + ) + with self.subTest("superuser delete, once deactivated"): + # Deleting a device additionally requires the device + # to be deactivated first, a precondition unrelated + # to the disabled-org policy; satisfy it here to isolate and + # confirm that a disabled organization's device is deletable. + device._is_deactivated = True + device.save(update_fields=["_is_deactivated"]) + admin = self._get_admin() + auth = self._disabled_org_api_auth(admin) + self._test_disabled_org_api_delete( + reverse("config_api:device_detail", args=[device.pk]), + auth, + Device, + device.pk, + ) + + def test_devicegroup_disabled_org_api_crud(self): + org = self._create_org(name="disabled-org", slug="disabled-org") + device_group = self._create_device_group( + name="disabled-group", organization=org + ) + org.is_active = False + org.save(update_fields=["is_active"]) + self._test_disabled_org_api_crud( + device_group, + detail_url=reverse("config_api:devicegroup_detail", args=[device_group.pk]), + list_url=reverse("config_api:devicegroup_list"), + create_payload={**self._devicegroup_data, "organization": str(org.pk)}, + update_payload={"name": "renamed-group"}, + ) + + def test_template_disabled_org_api_crud(self): + org = self._create_org(name="disabled-org", slug="disabled-org") + template = self._create_template(name="disabled-template", organization=org) + org.is_active = False + org.save(update_fields=["is_active"]) + self._test_disabled_org_api_crud( + template, + detail_url=reverse("config_api:template_detail", args=[template.pk]), + list_url=reverse("config_api:template_list"), + create_payload={**self._template_data, "organization": str(org.pk)}, + update_payload={"name": "renamed-template"}, + ) + + def test_vpn_disabled_org_api_crud(self): + org = self._create_org(name="disabled-org", slug="disabled-org") + vpn = self._create_vpn(name="disabled-vpn", organization=org) + org.is_active = False + org.save(update_fields=["is_active"]) + self._test_disabled_org_api_crud( + vpn, + detail_url=reverse("config_api:vpn_detail", args=[vpn.pk]), + list_url=reverse("config_api:vpn_list"), + create_payload={ + **self._vpn_data, + "organization": str(org.pk), + "ca": str(vpn.ca.pk), + }, + update_payload={"name": "renamed-vpn"}, + ) + def test_device_delete_api(self): self._create_template(required=True) device = self._create_device() diff --git a/openwisp_controller/config/tests/test_config.py b/openwisp_controller/config/tests/test_config.py index be157d984..a282e56d8 100644 --- a/openwisp_controller/config/tests/test_config.py +++ b/openwisp_controller/config/tests/test_config.py @@ -476,6 +476,19 @@ def test_multiple_vpn_clients(self): config.templates.set((template1, template2)) self.assertEqual(config.vpnclient_set.count(), 2) + def test_manage_vpn_clients_skips_disabled_org(self): + org = self._get_org() + vpn = self._create_vpn(organization=org) + template = self._create_template( + name="test-network", type="vpn", vpn=vpn, organization=org + ) + config = self._create_config(device=self._create_device(organization=org)) + org.is_active = False + org.save(update_fields=["is_active"]) + config.templates.add(template) + config.save() + self.assertEqual(config.vpnclient_set.count(), 0) + def test_create_cert(self): vpn = self._create_vpn() t = self._create_template( diff --git a/openwisp_controller/config/tests/test_controller.py b/openwisp_controller/config/tests/test_controller.py index 2fb85fe2a..9365cd66e 100644 --- a/openwisp_controller/config/tests/test_controller.py +++ b/openwisp_controller/config/tests/test_controller.py @@ -13,6 +13,7 @@ from openwisp_utils.tests import capture_any_output, catch_signal from .. import settings as app_settings +from .. import tasks from ..base.base import logger as base_config_logger from ..controller.views import DeviceChecksumView, VpnChecksumView from ..controller.views import logger as controller_views_logger @@ -63,17 +64,6 @@ def _get_reregistration_payload(self, device, **kwargs): data.update(**kwargs) return data - -class TestController( - TestRegistrationMixin, CreateConfigTemplateMixin, TestVpnX509Mixin, TestCase -): - """ - tests for config.controller - """ - - def _check_header(self, response): - self.assertEqual(response["X-Openwisp-Controller"], "true") - def _test_view_organization_disabled( self, obj, url, http_method="get", org=None, data=None ): @@ -89,6 +79,17 @@ def _test_view_organization_disabled( response = method(url, {"key": obj.key}) self.assertEqual(response.status_code, 404) + +class TestController( + TestRegistrationMixin, CreateConfigTemplateMixin, TestVpnX509Mixin, TestCase +): + """ + tests for config.controller + """ + + def _check_header(self, response): + self.assertEqual(response["X-Openwisp-Controller"], "true") + def test_device_checksum(self): d = self._create_device_config() c = d.config @@ -269,12 +270,6 @@ def test_vpn_checksum_405(self): ) self.assertEqual(response.status_code, 405) - def test_vpn_checksum_org_disabled(self): - vpn = self._create_vpn(organization=self._get_org()) - self._test_view_organization_disabled( - vpn, reverse("controller:vpn_checksum", args=[vpn.pk]) - ) - def test_vpn_get_object_cached(self): vpn = self._create_vpn() view = VpnChecksumView() @@ -339,13 +334,6 @@ def test_vpn_download_config_405(self): ) self.assertEqual(response.status_code, 405) - def test_vpn_download_config_org_disabled(self): - vpn = self._create_vpn(organization=self._get_org()) - self._test_view_organization_disabled( - vpn, - reverse("controller:vpn_download_config", args=[vpn.pk]), - ) - def test_register(self, **kwargs): options = { "hardware_id": "1234", @@ -1234,22 +1222,59 @@ def test_register_403_disabled_org(self): ) self.assertContains(response, "error: unrecognized secret", status_code=403) - def test_checksum_404_disabled_org(self): - org = self._create_org() - c = self._create_config(organization=org) - # Cache checksum - response = self.client.get( - reverse("controller:device_checksum", args=[c.device.pk]), - {"key": c.device.key}, + @capture_any_output() + def test_register_reregistration_403_disabled_org(self): + org = self._get_org() + device = self._create_device( + organization=org, + key=TEST_CONSISTENT_KEY, + mac_address=TEST_MACADDR, + name=TEST_MACADDR_NAME, ) - self.assertEqual(response.status_code, 200) + self._create_config(device=device) org.is_active = False - org.save() - response = self.client.get( - reverse("controller:device_checksum", args=[c.device.pk]), - {"key": c.device.key}, + org.save(update_fields=["is_active"]) + payload = self._get_reregistration_payload(device, name=TEST_MACADDR_NAME) + response = self.client.post(self.register_url, payload) + self.assertContains(response, "error: unrecognized secret", status_code=403) + + @capture_any_output() + def test_register_reregistration_403_cross_tenant_secret(self): + org = self._get_org() + device = self._create_device( + organization=org, + key=TEST_CONSISTENT_KEY, + mac_address=TEST_MACADDR, + name=TEST_MACADDR_NAME, ) - self.assertEqual(response.status_code, 404) + self._create_config(device=device) + org.is_active = False + org.save(update_fields=["is_active"]) + other_org = self._create_org( + shared_secret="other_org_secret", name="other-org", slug="other-org" + ) + payload = self._get_reregistration_payload(device, name=TEST_MACADDR_NAME) + payload["secret"] = other_org.config_settings.shared_secret + response = self.client.post(self.register_url, payload) + self.assertContains(response, "error: unrecognized secret", status_code=403) + + @capture_any_output() + def test_register_reregistration_403_cross_tenant_secret_both_active(self): + org = self._get_org() + device = self._create_device( + organization=org, + key=TEST_CONSISTENT_KEY, + mac_address=TEST_MACADDR, + name=TEST_MACADDR_NAME, + ) + self._create_config(device=device) + other_org = self._create_org( + shared_secret="other_org_secret", name="other-org", slug="other-org" + ) + payload = self._get_reregistration_payload(device, name=TEST_MACADDR_NAME) + payload["secret"] = other_org.config_settings.shared_secret + response = self.client.post(self.register_url, payload) + self.assertContains(response, "error: unrecognized secret", status_code=403) def test_download_config_404_disabled_org(self): org = self._create_org(is_active=False) @@ -1448,6 +1473,71 @@ def _test_deactivating_deactivated_device_view( config.refresh_from_db() self.assertEqual(config.status, "deactivated") + def test_checksum_404_disabled_org(self): + org = self._create_org() + config = self._create_config(organization=org) + device = config.device + # Cache checksum + response = self.client.get( + reverse("controller:device_checksum", args=[device.pk]), + {"key": device.key}, + ) + self.assertEqual(response.status_code, 200) + org.is_active = False + org.save() + response = self.client.get( + reverse("controller:device_checksum", args=[device.pk]), + {"key": device.key}, + ) + self.assertEqual(response.status_code, 404) + + def test_report_status_deactivating_allowed_disabled_org(self): + org = self._get_org() + self._create_template(required=True, organization=org) + device = self._create_device_config(device_opts={"organization": org}) + c = device.config + org.is_active = False + org.save(update_fields=["is_active"]) + tasks.deactivate_organization_devices(org.id) + c.refresh_from_db() + self.assertEqual(c.status, "deactivating") + + with self.subTest("download configuration"): + response = self.client.get( + reverse("controller:device_download_config", args=[device.pk]), + {"key": device.key}, + ) + self.assertEqual(response.status_code, 200) + + with self.subTest("download checksum"): + response = self.client.get( + reverse("controller:device_checksum", args=[device.pk]), + {"key": device.key}, + ) + self.assertEqual(response.status_code, 200) + + with self.subTest("report status"): + response = self.client.post( + reverse("controller:device_report_status", args=[device.pk]), + {"key": device.key, "status": "applied"}, + ) + self.assertEqual(response.status_code, 200) + c.refresh_from_db() + self.assertEqual(c.status, "deactivated") + + def test_vpn_checksum_org_disabled(self): + vpn = self._create_vpn(organization=self._get_org()) + self._test_view_organization_disabled( + vpn, reverse("controller:vpn_checksum", args=[vpn.pk]) + ) + + def test_vpn_download_config_org_disabled(self): + vpn = self._create_vpn(organization=self._get_org()) + self._test_view_organization_disabled( + vpn, + reverse("controller:vpn_download_config", args=[vpn.pk]), + ) + def test_device_config_deactivated_checksum(self): self._test_deactivating_deactivated_device_view("device_checksum") diff --git a/openwisp_controller/config/tests/test_device.py b/openwisp_controller/config/tests/test_device.py index ba15c0103..4d55227a3 100644 --- a/openwisp_controller/config/tests/test_device.py +++ b/openwisp_controller/config/tests/test_device.py @@ -482,6 +482,25 @@ def test_manage_devices_group_templates_skips_deactivated_devices(self): # Status must remain "deactivated" — no config push is initiated. self.assertEqual(device.config.status, "deactivated") + def test_manage_devices_group_templates_skips_disabled_org(self): + org = self._get_org() + old_template = self._create_template(name="old-template", organization=org) + new_template = self._create_template(name="new-template", organization=org) + old_group = self._create_device_group(name="old-group", organization=org) + new_group = self._create_device_group(name="new-group", organization=org) + old_group.templates.add(old_template) + new_group.templates.add(new_template) + device = self._create_device(name="test", organization=org, group=old_group) + self.assertEqual(device.config.templates.count(), 1) + org.is_active = False + org.save(update_fields=["is_active"]) + Device.manage_devices_group_templates(device.pk, old_group.pk, new_group.pk) + device.config.refresh_from_db() + # Devices of a disabled organization are skipped: their templates + # must not be updated while the organization is disabled. + self.assertEqual(device.config.templates.count(), 1) + self.assertNotIn(new_template, device.config.templates.all()) + @mock.patch.object(app_settings, "WHOIS_CONFIGURED", True) def test_changed_checked_fields_no_duplicates(self): """Ensure `_changed_checked_fields` contains `last_ip` only once. @@ -583,6 +602,16 @@ def test_create_default_config_existing(self): self.assertEqual(device.config.context, {"ssid": "test"}) self.assertEqual(device.config.config, {"general": {}}) + def test_create_default_config_skipped_for_disabled_org(self): + org = self._get_org() + template = self._create_template() + group = self._create_device_group(organization=org) + group.templates.add(template) + org.is_active = False + org.save(update_fields=["is_active"]) + device = self._create_device(organization=org, group=group) + self.assertFalse(hasattr(device, "config")) + class TestTransactionDevice( CreateConfigTemplateMixin, diff --git a/openwisp_controller/config/tests/test_device_group.py b/openwisp_controller/config/tests/test_device_group.py index 476a2f1f6..a9e343b52 100644 --- a/openwisp_controller/config/tests/test_device_group.py +++ b/openwisp_controller/config/tests/test_device_group.py @@ -11,6 +11,7 @@ from ..signals import group_templates_changed from .utils import CreateDeviceGroupMixin, CreateTemplateMixin +Device = load_model("config", "Device") DeviceGroup = load_model("config", "DeviceGroup") @@ -61,3 +62,21 @@ def test_device_group_signals(self): raw=False, using="default", ) + + def test_manage_group_templates_skips_disabled_org(self): + org = self._get_org() + template = self._create_template(name="new-template", organization=org) + device_group = self._create_device_group(organization=org) + device = Device( + name="test-device", + organization=org, + mac_address="00:11:22:33:44:55", + group=device_group, + ) + device.full_clean() + device.save() + org.is_active = False + org.save(update_fields=["is_active"]) + DeviceGroup.manage_group_templates(device_group.pk, [], [template.pk]) + device = Device.objects.get(pk=device.pk) + self.assertEqual(hasattr(device, "config"), False) diff --git a/openwisp_controller/config/tests/test_handlers.py b/openwisp_controller/config/tests/test_handlers.py index de4981224..f807f35f2 100644 --- a/openwisp_controller/config/tests/test_handlers.py +++ b/openwisp_controller/config/tests/test_handlers.py @@ -1,37 +1,110 @@ -from unittest.mock import patch +from unittest.mock import DEFAULT, patch -from django.test import TestCase - -from openwisp_users.tests.utils import TestOrganizationMixin +from django.test import TransactionTestCase +from swapper import load_model from .. import tasks +from .utils import CreateConfigMixin, Device + +Organization = load_model("openwisp_users", "Organization") -class TestHandlers(TestOrganizationMixin, TestCase): - @patch.object(tasks.invalidate_controller_views_cache, "delay") - def test_organization_disabled_handler(self, mocked_task): +class TestHandlers(CreateConfigMixin, TransactionTestCase): + @patch("openwisp_controller.config.handlers.chain") + def test_organization_disabled_handler(self, mocked_chain): with self.subTest("Test task not executed on creating active orgs"): org = self._create_org() - mocked_task.assert_not_called() + mocked_chain.assert_not_called() with self.subTest("Test task executed on changing active to inactive org"): + observed = {} + + def _record_db_state(*args, **kwargs): + observed["is_active"] = ( + Organization.objects.only("is_active").get(pk=org.pk).is_active + ) + return DEFAULT + + mocked_chain.side_effect = _record_db_state org.is_active = False org.save() - mocked_task.assert_called_once_with(str(org.id)) + mocked_chain.assert_called_once_with( + tasks.deactivate_organization_devices.s(str(org.id)), + tasks.invalidate_controller_views_cache.si(str(org.id)), + ) + mocked_chain.return_value.delay.assert_called_once() + # Dispatch happens only after the is_active write has committed. + self.assertEqual(observed["is_active"], False) + mocked_chain.side_effect = None - mocked_task.reset_mock() + mocked_chain.reset_mock() with self.subTest("Test task not executed on saving inactive org"): org.name = "Changed named" org.save() - mocked_task.assert_not_called() + mocked_chain.assert_not_called() with self.subTest("Test task not executed on creating inactive org"): - inactive_org = self._create_org( - is_active=False, name="inactive", slug="inactive" - ) - mocked_task.assert_not_called() + self._create_org(is_active=False, name="inactive", slug="inactive") + mocked_chain.assert_not_called() with self.subTest("Test task not executed on changing inactive to active org"): - inactive_org.is_active = True - inactive_org.save() - mocked_task.assert_not_called() + org.is_active = True + org.save() + mocked_chain.assert_not_called() + + def test_deactivate_organization_devices(self): + org = self._create_org() + device = self._create_device(organization=org) + config = self._create_config(device=device) + device = config.device + + with self.subTest("Devices are deactivated when org gets disabled"): + org.is_active = False + org.save() + tasks.deactivate_organization_devices(org.id) + device.refresh_from_db() + config.refresh_from_db() + self.assertEqual(device._is_deactivated, True) + self.assertIn(config.status, ("deactivating", "deactivated")) + + with self.subTest("Re-enabling org does not reactivate devices"): + org.is_active = True + org.save() + device.refresh_from_db() + self.assertEqual(device._is_deactivated, True) + + def test_deactivate_organization_devices_partial_failure(self): + org = self._create_org() + failing_device = self._create_device( + organization=org, name="failing-device", mac_address="00:11:22:33:44:01" + ) + self._create_config(device=failing_device) + ok_device = self._create_device( + organization=org, name="ok-device", mac_address="00:11:22:33:44:02" + ) + self._create_config(device=ok_device) + with patch("openwisp_controller.config.handlers.chain"): + org.is_active = False + org.save() + original_deactivate = Device.deactivate + + def _deactivate_side_effect(self, *args, **kwargs): + if self.pk == failing_device.pk: + raise Exception("Simulated deactivation failure") + return original_deactivate(self, *args, **kwargs) + + with patch.object(tasks, "logger") as mocked_logger: + with patch.object( + Device, + "deactivate", + autospec=True, + side_effect=_deactivate_side_effect, + ): + tasks.deactivate_organization_devices(org.id) + mocked_logger.exception.assert_called_once_with( + "Failed to deactivate device %s while disabling organization %s", + failing_device.pk, + org.id, + ) + ok_device.refresh_from_db() + self.assertEqual(ok_device._is_deactivated, True) diff --git a/openwisp_controller/config/tests/test_vpn.py b/openwisp_controller/config/tests/test_vpn.py index e1611e740..a603d56b9 100644 --- a/openwisp_controller/config/tests/test_vpn.py +++ b/openwisp_controller/config/tests/test_vpn.py @@ -426,6 +426,27 @@ def test_auto_create_cert_with_long_device_name(self): self.assertEqual(cert.count(), 1) self.assertEqual(cert.first().common_name[:-9], client._get_common_name()[:-9]) + def test_auto_create_cert_skipped_for_disabled_org(self): + org = self._get_org() + org.is_active = False + org.save(update_fields=["is_active"]) + vpn = self._create_vpn(organization=org) + self.assertIsNone(vpn.cert) + + def test_vpnclient_auto_create_cert_skipped_for_disabled_org(self): + org = self._get_org() + vpn = self._create_vpn(organization=org) + d = self._create_device(organization=org) + c = self._create_config(device=d) + org.is_active = False + org.save(update_fields=["is_active"]) + client = VpnClient( + vpn=vpn, config=c, auto_cert=True, template=self._create_template() + ) + client.full_clean() + client.save() + self.assertIsNone(client.cert) + @mock.patch.object(Vpn, "dhparam", side_effect=SoftTimeLimitExceeded) def test_update_vpn_dh_timeout(self, dhparam): vpn = self._create_vpn(dh="") @@ -512,6 +533,14 @@ def test_placeholder_dh_set(self, delay): self.assertEqual(vpn.dh, Vpn._placeholder_dh) delay.assert_called_once_with(vpn.pk) + @mock.patch.object(create_vpn_dh, "delay") + def test_create_vpn_dh_skipped_for_disabled_org(self, delay): + org = self._get_org() + org.is_active = False + org.save(update_fields=["is_active"]) + self._create_vpn(dh="", organization=org) + delay.assert_not_called() + @mock.patch.object(Vpn, "dhparam") def test_update_vpn_dh(self, dhparam): dhparam.return_value = self._dh @@ -629,6 +658,15 @@ def test_cert_renew_cascades_to_client_config(self): class TestWireguard(BaseTestVpn, TestWireguardVpnMixin, TestCase): + def test_wireguard_keys_and_ip_skipped_for_disabled_org(self): + org = self._get_org() + org.is_active = False + org.save(update_fields=["is_active"]) + vpn = self._create_wireguard_vpn(organization=org) + self.assertEqual(vpn.public_key, "") + self.assertEqual(vpn.private_key, "") + self.assertIsNone(vpn.ip) + def test_wireguard_config_creation(self): vpn = self._create_wireguard_vpn() @@ -817,6 +855,23 @@ def test_trigger_vpn_server_endpoint_invalid_vpn_id(self): f"VPN Server UUID: {vpn_id} does not exist." ) + @mock.patch("openwisp_controller.config.tasks.requests.post") + def test_trigger_vpn_server_endpoint_disabled_org(self, mocked_post): + # The webhook must still fire for a disabled organization's VPN so that + # peer removals triggered by cascading device deactivation reach the server. + # Peer additions are already prevented upstream because disabled + # organizations cannot create new devices or VPN clients. + org = self._get_org() + vpn = self._create_vpn(organization=org) + org.is_active = False + org.save(update_fields=["is_active"]) + trigger_vpn_server_endpoint( + endpoint="https://vpn_updater", + auth_token="secret", + vpn_id=str(vpn.id), + ) + mocked_post.assert_called_once() + class TestWireguardTransaction(BaseTestVpn, TestWireguardVpnMixin, TransactionTestCase): mock_response = mock.Mock(spec=requests.Response) @@ -973,6 +1028,26 @@ def test_vpn_peers_changed(self): class TestVxlan(BaseTestVpn, TestVxlanWireguardVpnMixin, TestCase): + def test_vpnclient_vni_skipped_for_disabled_org(self): + tunnel, subnet = self._create_vxlan_tunnel() + org = tunnel.organization + template = self._create_template( + name="vxlan-wireguard", + type="vpn", + vpn=tunnel, + organization=org, + auto_cert=True, + ) + device = self._create_device(organization=org) + config = self._create_config(device=device) + org.is_active = False + org.save(update_fields=["is_active"]) + client = VpnClient(vpn=tunnel, config=config, auto_cert=True, template=template) + client.full_clean() + client.save() + client.refresh_from_db() + self.assertIsNone(client.vni) + def test_vxlan_config_creation(self): tunnel, subnet = self._create_vxlan_tunnel() template = self._create_template( @@ -1200,6 +1275,22 @@ def _set_subprocess_mock(self, mock_sub): mock_stdout.stdout.decode.return_value = self._TEST_ZT_MEMBER_CONFIG["identity"] mock_sub.run.return_value = mock_stdout + @mock.patch(_ZT_SERVICE_REQUESTS) + def test_zerotier_network_and_ip_skipped_for_disabled_org(self, mock_requests): + # Host validation (a general full_clean() check, unrelated to + # organization state) still performs a single GET request; only + # the save()-time network/IP provisioning must be skipped. + mock_requests.get.side_effect = [ + self._get_mock_response(200, response=self._TEST_ZT_NODE_CONFIG) + ] + org = self._get_org() + org.is_active = False + org.save(update_fields=["is_active"]) + vpn = self._create_zerotier_vpn(organization=org) + self.assertEqual(vpn.network_id, "") + self.assertIsNone(vpn.ip) + mock_requests.post.assert_not_called() + @mock.patch(_ZT_SERVICE_REQUESTS) def test_zerotier_config_creation(self, mock_requests): mock_requests.get.side_effect = [ @@ -1235,6 +1326,31 @@ def test_zerotier_config_creation(self, mock_requests): self.assertIn("network_id", context_keys) self.assertIn("network_name", context_keys) + @mock.patch(_ZT_GENERATE_IDENTITY_SUBPROCESS) + @mock.patch(_ZT_SERVICE_REQUESTS) + def test_vpnclient_secret_skipped_for_disabled_org( + self, mock_requests, mock_subprocess + ): + mock_requests.get.side_effect = [ + self._get_mock_response(200, response=self._TEST_ZT_NODE_CONFIG) + ] + mock_requests.post.side_effect = [self._get_mock_response(200)] + org = self._get_org() + vpn = self._create_zerotier_vpn(organization=org) + template = self._create_template( + name="zerotier", type="vpn", vpn=vpn, organization=org, auto_cert=True + ) + device = self._create_device(organization=org) + config = self._create_config(device=device) + org.is_active = False + org.save(update_fields=["is_active"]) + client = VpnClient(vpn=vpn, config=config, auto_cert=True, template=template) + client.full_clean() + client.save() + client.refresh_from_db() + self.assertEqual(client.secret, "") + mock_subprocess.run.assert_not_called() + @mock.patch(_ZT_GENERATE_IDENTITY_SUBPROCESS) @mock.patch(_ZT_SERVICE_REQUESTS) def test_zerotier_auto_cert_false(self, mock_requests, mock_subprocess): diff --git a/openwisp_controller/config/whois/tasks.py b/openwisp_controller/config/whois/tasks.py index b95803025..88cb41bf6 100644 --- a/openwisp_controller/config/whois/tasks.py +++ b/openwisp_controller/config/whois/tasks.py @@ -65,7 +65,7 @@ def fetch_whois_details(self, device_pk, initial_ip_address): """ Device = load_model("config", "Device") WHOISInfo = load_model("config", "WHOISInfo") - device = Device.objects.filter(pk=device_pk).first() + device = Device.objects.select_related("organization").filter(pk=device_pk).first() if not device: logger.warning(f"Device {device_pk} not found, skipping WHOIS lookup") return @@ -74,6 +74,7 @@ def fetch_whois_details(self, device_pk, initial_ip_address): ip_address = normalize_ip(initial_ip_address) if ( device.is_deactivated() + or not device.organization.is_active or normalize_ip(device.last_ip) != ip_address or not whois_service.is_valid_public_ip_address(ip_address) or not whois_service.is_whois_enabled @@ -88,10 +89,16 @@ def fetch_whois_details(self, device_pk, initial_ip_address): fetched_details = whois_service.process_whois_details(ip_address) with transaction.atomic(): - device = Device.objects.select_for_update().filter(pk=device_pk).first() + device = ( + Device.objects.select_for_update(of=("self",)) + .select_related("organization") + .filter(pk=device_pk) + .first() + ) if ( not device or device.is_deactivated() + or not device.organization.is_active or normalize_ip(device.last_ip) != ip_address or not device.whois_service.is_whois_enabled ): diff --git a/openwisp_controller/config/whois/tests/tests.py b/openwisp_controller/config/whois/tests/tests.py index 5c3fbb18d..85472ca2f 100644 --- a/openwisp_controller/config/whois/tests/tests.py +++ b/openwisp_controller/config/whois/tests/tests.py @@ -1058,6 +1058,18 @@ def test_fetch_details_skips_when_deactivated(self, mock_client, mock_info): ) mock_client.assert_not_called() + @mock.patch.object(app_settings, "WHOIS_CONFIGURED", True) + @mock.patch(_WHOIS_GEOIP_CLIENT) + def test_fetch_details_skips_when_org_disabled(self, mock_client): + whois_obj = self._create_whois_info(ip_address="8.8.8.8") + device = self._create_device(last_ip=whois_obj.ip_address) + device.organization.is_active = False + device.organization.save(update_fields=["is_active"]) + fetch_whois_details( + device_pk=device.pk, initial_ip_address=whois_obj.ip_address + ) + mock_client.assert_not_called() + @mock.patch.object(app_settings, "WHOIS_CONFIGURED", True) @mock.patch(_WHOIS_GEOIP_CLIENT) def test_fetch_details_record_already_exists(self, mock_client): diff --git a/openwisp_controller/connection/api/serializers.py b/openwisp_controller/connection/api/serializers.py index 142c1c30a..4ec1335a8 100644 --- a/openwisp_controller/connection/api/serializers.py +++ b/openwisp_controller/connection/api/serializers.py @@ -21,7 +21,13 @@ def validate(self, data): return super().validate(data) -class CommandSerializer(ValidatedDeviceFieldSerializer): +class CommandSerializer(FilterSerializerByOrgManaged, ValidatedDeviceFieldSerializer): + # ``Command`` has no direct ``organization`` field (only ``device``), + # and ``connection``'s queryset (``DeviceConnection``) doesn't either; + # this tells ``FilterSerializerByOrganization.filter_fields`` how to + # reach it, mirroring ``DeviceConnectionSerializer`` below. + organization_field = "device__organization" + input = serializers.JSONField( allow_null=True, help_text=mark_safe( diff --git a/openwisp_controller/connection/api/views.py b/openwisp_controller/connection/api/views.py index 6af1270c7..191aa40fa 100644 --- a/openwisp_controller/connection/api/views.py +++ b/openwisp_controller/connection/api/views.py @@ -34,6 +34,7 @@ class BaseCommandView(RelatedDeviceProtectedAPIMixin): model = Command queryset = Command.objects.prefetch_related("device") serializer_class = CommandSerializer + select_related_organization = False def get_permissions(self): return super().get_permissions() + [RelatedDeviceModelPermission()] @@ -41,7 +42,7 @@ def get_permissions(self): def get_parent_queryset(self): return Device.objects.filter( pk=self.kwargs["device_id"], - ) + ).select_related("organization") def get_queryset(self): return ( @@ -112,6 +113,7 @@ class BaseDeviceConnection( model = DeviceConnection serializer_class = DeviceConnectionSerializer queryset = DeviceConnection.objects.prefetch_related("device") + select_related_organization = False def get_queryset(self): return ( @@ -127,7 +129,9 @@ def get_serializer_context(self): return context def get_parent_queryset(self): - return Device.objects.filter(pk=self.kwargs["device_id"]) + return Device.objects.filter(pk=self.kwargs["device_id"]).select_related( + "organization" + ) class DeviceConnectionListCreateView(BaseDeviceConnection, ListCreateAPIView): diff --git a/openwisp_controller/connection/base/models.py b/openwisp_controller/connection/base/models.py index 17a06f7bb..437824c51 100644 --- a/openwisp_controller/connection/base/models.py +++ b/openwisp_controller/connection/base/models.py @@ -381,6 +381,14 @@ def connect(self): # through so the final cleared configuration can still be pushed. if self.device.is_fully_deactivated(): raise RuntimeError(_("Device is deactivated")) + # Refuse devices of a disabled organization, unless deactivation is + # already in progress (device.is_deactivated() True): that case must + # stay allowed so the final cleared configuration can still be pushed. + if ( + not self.device.organization.is_active + and not self.device.is_deactivated() + ): + raise RuntimeError(_("Organization is disabled")) self.connector_instance.connect() except Exception as e: self.is_working = False @@ -592,7 +600,10 @@ def execute(self): # is still pending. if self.device.is_fully_deactivated(): self.status = "failed" - self._add_output("Device is deactivated.") + self._add_output(_("Device is deactivated.")) + elif not self.device.organization.is_active: + self.status = "failed" + self._add_output(_("Organization is disabled.")) else: exit_code = self._exec_command() # if output is None, the commands couldn't execute diff --git a/openwisp_controller/connection/tests/test_admin.py b/openwisp_controller/connection/tests/test_admin.py index 379346bc2..34845f41a 100644 --- a/openwisp_controller/connection/tests/test_admin.py +++ b/openwisp_controller/connection/tests/test_admin.py @@ -1,6 +1,7 @@ import json from unittest.mock import patch +from django.contrib import admin from django.contrib.auth.models import Permission from django.test import TestCase, override_settings from django.urls import reverse @@ -11,6 +12,7 @@ from ... import settings as module_settings from ...tests import _get_updated_templates_settings from ...tests.utils import TestAdminMixin +from ..admin import CommandWritableInline, DeviceConnectionInline from ..connectors.ssh import Ssh from ..widgets import CredentialsSchemaWidget from .utils import CreateConnectionsMixin @@ -95,6 +97,33 @@ def test_connection_credentials_fk_queryset(self): visible=[str(data["cred1"].name) + str(" (SSH)")], hidden=[str(data["cred2"].name) + str(" (SSH)"), data["cred3_inactive"]], select_widget=True, + superuser_hidden=[data["cred3_inactive"]], + ) + + def test_credentials_disabled_org_admin_crud(self): + org = self._create_org(name="disabled-org", slug="disabled-org") + credentials = self._create_credentials( + organization=org, name="disabled-credentials" + ) + org.is_active = False + org.save(update_fields=["is_active"]) + self._test_disabled_org_admin_crud( + credentials, + change_data={"name": "renamed-credentials"}, + create_data={ + "name": "new-credentials", + "organization": str(org.pk), + "connector": credentials.connector, + "params": credentials.params, + }, + ) + + def test_credentials_disabled_org_admin_org_field_excludes_disabled(self): + active_org = self._get_org() + disabled_org = self._create_org(name="disabled-org", is_active=False) + add_url = reverse(f"admin:{self.app_label}_credentials_add") + self._test_disabled_org_admin_org_field_excludes_disabled( + add_url, disabled_org, organization=active_org ) def test_credentials_jsonschema_widget_media(self): @@ -236,6 +265,24 @@ def test_command_writable_inline_without_permission(self): response = self.client.get(path) self.assertNotContains(response, "id_command_set") + def test_device_disabled_org_admin_inline_readonly(self): + active_device = self.device + disabled_org = self._create_org(name="disabled-org", is_active=False) + disabled_credentials = self._create_credentials( + organization=disabled_org, name="disabled-credentials" + ) + disabled_device_connection = self._create_device_connection( + credentials=disabled_credentials + ) + disabled_device = disabled_device_connection.device + model_admin = admin.site._registry[Device] + self._test_disabled_org_admin_inline_readonly( + model_admin, + disabled_device, + active_obj=active_device, + inline_models=(DeviceConnectionInline, CommandWritableInline), + ) + def test_commands_schema_view(self): url = reverse( f"admin:{Command._meta.app_label}_{Command._meta.model_name}_schema" diff --git a/openwisp_controller/connection/tests/test_api.py b/openwisp_controller/connection/tests/test_api.py index 6e3a827ab..56b43c65d 100644 --- a/openwisp_controller/connection/tests/test_api.py +++ b/openwisp_controller/connection/tests/test_api.py @@ -12,7 +12,7 @@ from swapper import load_model from openwisp_controller.tests.utils import TestAdminMixin -from openwisp_users.tests.test_api import AuthenticationMixin +from openwisp_users.tests.test_api import AuthenticationMixin, TestDisabledOrgApiMixin from .. import settings as app_settings from ..api.views import CommandListCreateView @@ -26,7 +26,9 @@ Group = load_model("openwisp_users", "Group") -class TestCommandsAPI(TestCase, AuthenticationMixin, CreateCommandMixin): +class TestCommandsAPI( + CreateCommandMixin, TestDisabledOrgApiMixin, AuthenticationMixin, TestCase +): url_namespace = "connection_api" def setUp(self): @@ -183,6 +185,38 @@ def test_command_attributes(self, payload): self.assertEqual(response.status_code, 201) test_command_attributes(self, payload) + def test_command_create_api_disabled_org(self): + self.device_conn.device.organization.is_active = False + self.device_conn.device.organization.save(update_fields=["is_active"]) + url = self._get_path("device_command_list", self.device_id) + payload = {"type": "custom", "input": {"command": "echo test"}} + response = self.client.post( + url, + data=json.dumps(payload), + content_type="application/json", + ) + self.assertEqual(response.status_code, 403) + + def test_command_disabled_org_api_crud(self): + org = self.device_conn.device.organization + command = self._create_command(device_conn=self.device_conn) + org.is_active = False + org.save(update_fields=["is_active"]) + list_url = self._get_path("device_command_list", self.device_id) + detail_url = self._get_path( + "device_command_details", self.device_id, command.id + ) + self._test_disabled_org_api_crud( + command, + detail_url=detail_url, + list_url=list_url, + create_payload={"type": "custom", "input": {"command": "echo test"}}, + operations=("list", "retrieve", "create"), + organization=org, + org_admin_expected={"create": {"status": 403}}, + superuser_expected={"create": {"status": 403}}, + ) + # for ensuring that only related connections are shown def test_available_connections(self): device = self._create_device( @@ -425,12 +459,35 @@ def test_create_command_without_connection(self): class TestConnectionApi( - TestAdminMixin, AuthenticationMixin, TestCase, CreateConnectionsMixin + TestAdminMixin, + CreateConnectionsMixin, + TestDisabledOrgApiMixin, + AuthenticationMixin, + TestCase, ): def setUp(self): super().setUp() self._login() + def test_credential_disabled_org_api_crud(self): + org = self._get_org() + cred = self._create_credentials(organization=org) + org.is_active = False + org.save(update_fields=["is_active"]) + self._test_disabled_org_api_crud( + cred, + detail_url=reverse("connection_api:credential_detail", args=[cred.pk]), + list_url=reverse("connection_api:credential_list"), + create_payload={ + "connector": "openwisp_controller.connection.connectors.ssh.Ssh", + "name": "new-credentials", + "organization": str(org.pk), + "auto_add": False, + "params": {"username": "root", "password": "password", "port": 22}, + }, + update_payload={"name": "renamed-credentials"}, + ) + def test_get_credentials_list(self): self._create_credentials() path = reverse("connection_api:credential_list") @@ -501,7 +558,7 @@ def test_put_credential_detail(self): }, } expected_queries = ( - 8 if parse_version(REST_FRAMEWORK_VERSION) >= parse_version("3.15") else 7 + 7 if parse_version(REST_FRAMEWORK_VERSION) >= parse_version("3.15") else 6 ) with self.assertNumQueries(expected_queries): response = self.client.put(path, data, content_type="application/json") @@ -519,7 +576,7 @@ def test_patch_credential_detail(self): cred = self._create_credentials() path = reverse("connection_api:credential_detail", args=(cred.pk,)) data = {"name": "Change Test credentials"} - with self.assertNumQueries(7): + with self.assertNumQueries(6): response = self.client.patch(path, data, content_type="application/json") self.assertEqual(response.status_code, 200) self.assertEqual(response.data["name"], "Change Test credentials") @@ -565,6 +622,52 @@ def test_post_deviceconnection_list(self): response = self.client.post(path, data, content_type="application/json") self.assertEqual(response.status_code, 201) + def test_post_deviceconnection_list_disabled_org(self): + d1 = self._create_device() + self._create_config(device=d1) + d1.organization.is_active = False + d1.organization.save(update_fields=["is_active"]) + path = reverse("connection_api:deviceconnection_list", args=(d1.pk,)) + data = { + "credentials": self._get_credentials().pk, + "update_strategy": app_settings.UPDATE_STRATEGIES[0][0], + "enabled": True, + "failure_reason": "", + } + response = self.client.post(path, data, content_type="application/json") + self.assertEqual(response.status_code, 403) + + def test_deviceconnection_disabled_org_api_crud(self): + dc = self._create_device_connection() + org = dc.device.organization + org.is_active = False + org.save(update_fields=["is_active"]) + list_url = reverse("connection_api:deviceconnection_list", args=(dc.device.pk,)) + detail_url = reverse( + "connection_api:deviceconnection_detail", args=(dc.device.pk, dc.pk) + ) + blocked_spec = { + "create": {"status": 403}, + "update": {"status": 403, "unchanged": True}, + } + self._test_disabled_org_api_crud( + dc, + detail_url=detail_url, + list_url=list_url, + create_payload={ + "credentials": self._get_credentials().pk, + "update_strategy": app_settings.UPDATE_STRATEGIES[0][0], + "enabled": True, + "failure_reason": "", + }, + update_payload={"enabled": False}, + unchanged_field="enabled", + operations=("list", "retrieve", "create", "update", "delete"), + organization=org, + org_admin_expected=blocked_spec, + superuser_expected=blocked_spec, + ) + def test_post_deviceconenction_with_no_config_device(self): d1 = self._create_device() path = reverse("connection_api:deviceconnection_list", args=(d1.pk,)) @@ -631,7 +734,7 @@ def test_delete_deviceconnection_detail(self): dc = self._create_device_connection() d1 = dc.device.id path = reverse("connection_api:deviceconnection_detail", args=(d1, dc.pk)) - with self.assertNumQueries(10): + with self.assertNumQueries(11): response = self.client.delete(path) self.assertEqual(response.status_code, 204) diff --git a/openwisp_controller/connection/tests/test_tasks.py b/openwisp_controller/connection/tests/test_tasks.py index 587c08209..54709ef8a 100644 --- a/openwisp_controller/connection/tests/test_tasks.py +++ b/openwisp_controller/connection/tests/test_tasks.py @@ -207,6 +207,27 @@ def test_launch_command_deactivating_device_not_blocked(self, mocked_exec_comman self.assertNotEqual(command.output, "Device is deactivated.\n") mocked_exec_command.assert_called_once() + @mock.patch( + "openwisp_controller.connection.base.models.AbstractCommand._exec_command" + ) + def test_launch_command_disabled_organization(self, mocked_exec_command): + dc = self._create_device_connection() + command = Command( + device=dc.device, + connection=dc, + type="custom", + input={"command": "/usr/sbin/exotic_command"}, + ) + command.full_clean() + command.save() + dc.device.organization.is_active = False + dc.device.organization.save(update_fields=["is_active"]) + tasks.launch_command.delay(command.pk) + command.refresh_from_db() + self.assertEqual(command.status, "failed") + self.assertEqual(command.output, "Organization is disabled.\n") + mocked_exec_command.assert_not_called() + @mock.patch( "openwisp_controller.connection.base.models.AbstractCommand._exec_command" ) diff --git a/openwisp_controller/geo/admin.py b/openwisp_controller/geo/admin.py index 615a34d66..7eafa9fc4 100644 --- a/openwisp_controller/geo/admin.py +++ b/openwisp_controller/geo/admin.py @@ -1,5 +1,6 @@ import reversion from django.contrib import admin +from django.contrib.admin.options import ModelAdmin as DjangoModelAdmin from django.utils.translation import gettext_lazy as _ from django_loci.base.admin import ( AbstractFloorPlanAdmin, @@ -46,6 +47,16 @@ class FloorPlanAdmin(MultitenantAdminMixin, AbstractFloorPlanAdmin): form = FloorPlanForm list_filter = [MultitenantOrgFilter, "created"] + def get_form(self, request, obj=None, **kwargs): + try: + return super().get_form(request, obj, **kwargs) + except KeyError as error: + if error.args != ("location",): + raise + form = DjangoModelAdmin.get_form(self, request, obj, **kwargs) + form._user = request.user + return form + FloorPlanAdmin.list_display.insert(1, "organization") diff --git a/openwisp_controller/geo/api/views.py b/openwisp_controller/geo/api/views.py index 9650d8230..24cd6fcf3 100644 --- a/openwisp_controller/geo/api/views.py +++ b/openwisp_controller/geo/api/views.py @@ -108,7 +108,7 @@ class DeviceCoordinatesView(ProtectedAPIMixin, generics.RetrieveUpdateAPIView): serializer_class = DeviceCoordinatesSerializer permission_classes = (DevicePermission,) queryset = Device.objects.select_related( - "devicelocation", "devicelocation__location" + "organization", "devicelocation", "devicelocation__location" ) def get_queryset(self): @@ -124,7 +124,9 @@ def get_location(self, device): def get_object(self, *args, **kwargs): device = super().get_object() - if self.request.method not in ("GET", "HEAD") and device.is_deactivated(): + if self.request.method not in ("GET", "HEAD") and ( + device.is_deactivated() or not device.organization.is_active + ): raise PermissionDenied location = self.get_location(device) if location: @@ -164,6 +166,7 @@ class DeviceLocationView( organization_field = "content_object__organization" organization_lookup = "organization__in" _device_field = "content_object" + select_related_organization = False def get_queryset(self): qs = super().get_queryset() @@ -173,7 +176,9 @@ def get_queryset(self): return qs.none() def get_parent_queryset(self): - return Device.objects.filter(pk=self.kwargs["pk"]) + return Device.objects.filter(pk=self.kwargs["pk"]).select_related( + "organization" + ) def get_serializer_context(self): context = super().get_serializer_context() @@ -205,8 +210,9 @@ def get_object_or_none(self): if self.request.method == "PUT": # For PUT-as-create operation, we need to ensure that we have # relevant permissions, as if this was a POST request. This - # will either raise a PermissionDenied exception, or simply - # return None. + # will either raise a PermissionDenied exception (covers a + # disabled organization too, since RelatedDeviceModelPermission + # checks device.organization.is_active) or simply return None. self.check_permissions(clone_request(self.request, "POST")) else: # PATCH requests where the object does not exist should still @@ -287,7 +293,9 @@ def get_parent_queryset(self): def get_queryset(self): super().get_queryset() - qs = Device.objects.filter(devicelocation__location_id=self.kwargs["pk"]) + qs = Device.objects.filter( + devicelocation__location_id=self.kwargs["pk"] + ).select_related("organization") return qs def get_has_floorplan(self, qs): @@ -308,6 +316,7 @@ class FloorPlanListCreateView(ProtectedAPIMixin, generics.ListCreateAPIView): pagination_class = OpenWispPagination filter_backends = [filters.DjangoFilterBackend] filterset_class = FloorPlanOrganizationFilter + select_related_organization = False class FloorPlanDetailView( @@ -316,6 +325,7 @@ class FloorPlanDetailView( ): serializer_class = FloorPlanSerializer queryset = FloorPlan.objects.select_related() + select_related_organization = False class LocationListCreateView(ProtectedAPIMixin, generics.ListCreateAPIView): diff --git a/openwisp_controller/geo/estimated_location/tasks.py b/openwisp_controller/geo/estimated_location/tasks.py index 1ca233024..b5262ad43 100644 --- a/openwisp_controller/geo/estimated_location/tasks.py +++ b/openwisp_controller/geo/estimated_location/tasks.py @@ -26,10 +26,14 @@ def manage_estimated_locations(device_pk, ip_address): # (PostgreSQL cannot lock nullable joined rows). device = ( Device.objects.select_for_update(of=("self",)) - .select_related("devicelocation__location") + .select_related("organization", "devicelocation__location") .get(pk=device_pk) ) - if device.is_deactivated() or normalize_ip(device.last_ip) != ip_address: + if ( + device.is_deactivated() + or not device.organization.is_active + or normalize_ip(device.last_ip) != ip_address + ): logger.info( f"Device {device_pk} no longer needs estimated location " f"for {ip_address}" diff --git a/openwisp_controller/geo/estimated_location/tests/tests.py b/openwisp_controller/geo/estimated_location/tests/tests.py index b3f0d3772..b2de2902e 100644 --- a/openwisp_controller/geo/estimated_location/tests/tests.py +++ b/openwisp_controller/geo/estimated_location/tests/tests.py @@ -1026,6 +1026,19 @@ def test_manage_locations_skips_when_deactivated(self, mock_info): f"for {device.last_ip}" ) + @mock.patch.object(config_app_settings, "WHOIS_CONFIGURED", True) + @mock.patch(_ESTIMATED_LOCATION_INFO_LOGGER) + def test_manage_locations_skips_when_org_disabled(self, mock_info): + whois_obj = self._create_whois_info(ip_address="172.217.22.14") + device = self._create_device(last_ip=whois_obj.ip_address) + device.organization.is_active = False + device.organization.save(update_fields=["is_active"]) + manage_estimated_locations(device.pk, device.last_ip) + mock_info.assert_called_once_with( + f"Device {device.pk} no longer needs estimated location " + f"for {device.last_ip}" + ) + @mock.patch.object(config_app_settings, "WHOIS_CONFIGURED", True) @mock.patch( "openwisp_controller.geo.estimated_location.service.current_app.send_task", diff --git a/openwisp_controller/geo/tests/test_admin.py b/openwisp_controller/geo/tests/test_admin.py index 8fd113cad..61b8af897 100644 --- a/openwisp_controller/geo/tests/test_admin.py +++ b/openwisp_controller/geo/tests/test_admin.py @@ -1,8 +1,9 @@ from unittest import mock +from django.contrib import admin from django.contrib.auth import get_user_model from django.core.files.base import ContentFile -from django.test import TestCase +from django.test import RequestFactory, TestCase from django.urls import reverse from django_loci.tests.base.test_admin import BaseTestAdmin from swapper import load_model @@ -12,6 +13,7 @@ from ...config import settings as config_app_settings from ...config.tests.test_admin import TestImportExportMixin from ...tests.utils import TestAdminMixin +from ..admin import DeviceLocationInline, FloorPlanAdmin from .utils import TestGeoMixin Device = load_model("config", "Device") @@ -129,6 +131,60 @@ def test_floorplan_queryset(self): ], ) + def test_location_disabled_org_admin_crud(self): + org = self._create_organization(name="loc-disabled-org") + location = self._create_location( + name="loc-disabled", type="indoor", organization=org + ) + org.is_active = False + org.save() + self._test_disabled_org_admin_crud( + location, + change_data={"name": "renamed-location"}, + create_data={ + "name": "new-location", + "organization": str(org.pk), + "type": "indoor", + }, + ) + + def test_floorplan_disabled_org_admin_crud(self): + org = self._create_organization(name="fl-disabled-org") + location = self._create_location( + name="fl-disabled-location", type="indoor", organization=org + ) + floorplan = self._create_floorplan(location=location, organization=org) + org.is_active = False + org.save() + self._test_disabled_org_admin_crud( + floorplan, + change_data={"floor": 2}, + unchanged_field="floor", + create_data={"floor": 3, "location": str(location.pk)}, + ) + + def test_location_disabled_org_admin_org_field_excludes_disabled(self): + self._create_admin() + active_org = self._get_org() + disabled_org = self._create_organization( + name="loc-disabled-org", is_active=False + ) + add_url = reverse(f"admin:{self.app_label}_location_add") + self._test_disabled_org_admin_org_field_excludes_disabled( + add_url, disabled_org, organization=active_org + ) + + def test_floorplan_disabled_org_admin_org_field_excludes_disabled(self): + self._create_admin() + active_org = self._get_org() + disabled_org = self._create_organization( + name="fl-disabled-org", is_active=False + ) + add_url = reverse(f"admin:{self.app_label}_floorplan_add") + self._test_disabled_org_admin_org_field_excludes_disabled( + add_url, disabled_org, organization=active_org + ) + def test_admin_menu_groups(self): # Test menu group (openwisp-utils menu group) for Location , FloorPlan @@ -156,6 +212,30 @@ def test_location_readonly_fields(self): response = self.client.get(url) self.assertNotContains(response, 'Cas & Certificates ', html=True, ) + + def test_ca_disabled_org_admin_crud(self): + org = self._create_org(name="disabled-org", slug="disabled-org") + ca = self._create_ca(name="disabled-ca", organization=org) + org.is_active = False + org.save(update_fields=["is_active"]) + self._test_disabled_org_admin_crud( + ca, + change_data={"name": "renamed-ca"}, + create_data={"name": "new-ca", "organization": str(org.pk)}, + ) + + def test_ca_disabled_org_admin_org_field_excludes_disabled(self): + active_org = self._get_org() + disabled_org = self._create_org(name="disabled-org", is_active=False) + add_url = reverse(f"admin:{self.app_label}_ca_add") + self._test_disabled_org_admin_org_field_excludes_disabled( + add_url, disabled_org, organization=active_org + ) + + def test_cert_disabled_org_admin_crud(self): + org = self._create_org(name="disabled-org", slug="disabled-org") + ca = self._create_ca(name="disabled-ca", organization=org) + cert = self._create_cert(name="disabled-cert", ca=ca, organization=org) + org.is_active = False + org.save(update_fields=["is_active"]) + self._test_disabled_org_admin_crud( + cert, + change_data={"name": "renamed-cert"}, + create_data={ + "name": "new-cert", + "organization": str(org.pk), + "ca": str(ca.pk), + }, + ) + + def test_cert_disabled_org_admin_org_field_excludes_disabled(self): + active_org = self._get_org() + disabled_org = self._create_org(name="disabled-org", is_active=False) + add_url = reverse(f"admin:{self.app_label}_cert_add") + self._test_disabled_org_admin_org_field_excludes_disabled( + add_url, disabled_org, organization=active_org + ) + + def test_ca_renew_action_skips_disabled_org(self): + self.client.force_login(self._get_admin()) + org = self._get_org() + ca = self._create_ca(name="ca-disabled", organization=org) + cert = self._create_cert(name="cert-disabled", ca=ca, organization=org) + old_serial = ca.serial_number + old_cert_serial = cert.serial_number + org.is_active = False + org.save(update_fields=["is_active"]) + path = reverse(f"admin:{self.app_label}_ca_changelist") + payload = { + "action": "renew_ca", + "_selected_action": [ca.pk], + "post": "yes", + } + response = self.client.post(path, payload, follow=True) + ca.refresh_from_db() + cert.refresh_from_db() + self.assertEqual(str(ca.serial_number), str(old_serial)) + # renew_ca cascades to child certs when it runs: a skipped CA + # renewal must also leave its certs untouched + self.assertEqual(str(cert.serial_number), str(old_cert_serial)) + self.assertContains( + response, "Actions cannot modify objects of disabled organizations." + ) + + def test_cert_revoke_action_allowed_for_disabled_org(self): + self.client.force_login(self._get_admin()) + org = self._get_org() + ca = self._create_ca(name="ca-disabled", organization=org) + cert = self._create_cert(name="cert-disabled", ca=ca, organization=org) + org.is_active = False + org.save(update_fields=["is_active"]) + changelist = reverse(f"admin:{self.app_label}_cert_changelist") + revoke_payload = {"action": "revoke_action", "_selected_action": [cert.pk]} + self.client.post(changelist, revoke_payload, follow=True) + cert.refresh_from_db() + self.assertEqual(cert.revoked, True) + self.assertIn(cert, list(ca.get_revoked_certs())) + + def test_cert_renew_action_skips_disabled_org(self): + self.client.force_login(self._get_admin()) + org = self._get_org() + ca = self._create_ca(name="ca-disabled", organization=org) + cert = self._create_cert(name="cert-disabled", ca=ca, organization=org) + org.is_active = False + org.save(update_fields=["is_active"]) + changelist = reverse(f"admin:{self.app_label}_cert_changelist") + old_serial = cert.serial_number + renew_payload = { + "action": "renew_cert", + "_selected_action": [cert.pk], + "post": "yes", + } + response = self.client.post(changelist, renew_payload, follow=True) + cert.refresh_from_db() + self.assertEqual(str(cert.serial_number), str(old_serial)) + self.assertContains( + response, "Actions cannot modify objects of disabled organizations." + ) diff --git a/openwisp_controller/pki/tests/test_api.py b/openwisp_controller/pki/tests/test_api.py index 51faebdaf..99d638af7 100644 --- a/openwisp_controller/pki/tests/test_api.py +++ b/openwisp_controller/pki/tests/test_api.py @@ -5,7 +5,7 @@ from swapper import load_model from openwisp_controller.tests.utils import TestAdminMixin -from openwisp_users.tests.test_api import AuthenticationMixin +from openwisp_users.tests.test_api import AuthenticationMixin, TestDisabledOrgApiMixin from openwisp_users.tests.utils import TestOrganizationMixin from openwisp_utils.tests import AssertNumQueriesSubTestMixin, capture_any_output @@ -19,7 +19,7 @@ class TestPkiApi( AssertNumQueriesSubTestMixin, TestAdminMixin, TestPkiMixin, - TestOrganizationMixin, + TestDisabledOrgApiMixin, AuthenticationMixin, TestCase, ): @@ -137,7 +137,7 @@ def test_ca_patch_api(self): data = { "name": "change-ca1", } - with self.assertNumQueries(6): + with self.assertNumQueries(5): r = self.client.patch(path, data, content_type="application/json") self.assertEqual(r.status_code, 200) self.assertEqual(r.data["name"], "change-ca1") @@ -168,6 +168,42 @@ def test_ca_post_renew_api(self): self.assertNotEqual(ca1.serial_number, old_serial_num) self.assertNotEqual(r.data["serial_number"], old_serial_num) + def test_ca_post_renew_api_disabled_org(self): + org = self._get_org() + ca1 = self._create_ca(name="ca1", organization=org) + old_serial_num = ca1.serial_number + org.is_active = False + org.save(update_fields=["is_active"]) + path = reverse("pki_api:ca_renew", args=[ca1.pk]) + r = self.client.post(path) + ca1.refresh_from_db() + self.assertEqual(r.status_code, 403) + self.assertEqual(str(ca1.serial_number), str(old_serial_num)) + + def test_ca_disabled_org_api_crud(self): + org = self._create_org(name="disabled-org", slug="disabled-org") + ca = self._create_ca(name="disabled-ca", organization=org) + org.is_active = False + org.save(update_fields=["is_active"]) + create_payload = self._ca_data + create_payload["organization"] = str(org.pk) + self._test_disabled_org_api_crud( + ca, + detail_url=reverse("pki_api:ca_detail", args=[ca.pk]), + list_url=reverse("pki_api:ca_list"), + create_payload=create_payload, + update_payload={"name": "renamed-ca"}, + ) + + def test_crl_download_disabled_org(self): + org = self._create_org(name="disabled-org", slug="disabled-org") + ca = self._create_ca(name="disabled-ca", organization=org) + org.is_active = False + org.save(update_fields=["is_active"]) + path = reverse("pki_api:crl_download", args=[ca.pk]) + r = self.client.get(path) + self.assertEqual(r.status_code, 200) + def test_cert_post_api(self): path = reverse("pki_api:cert_list") data = self._cert_data @@ -211,6 +247,51 @@ def test_cert_post_with_extensions_field(self): self.assertEqual(Cert.objects.count(), 1) self.assertEqual(r.data["extensions"], []) + def test_cert_revoke_api_allowed_for_disabled_org(self): + org = self._get_org() + ca = self._create_ca(organization=org) + cert = self._create_cert(ca=ca, organization=org) + org.is_active = False + org.save(update_fields=["is_active"]) + revoke_path = reverse("pki_api:cert_revoke", args=[cert.pk]) + revoke_response = self.client.post(revoke_path) + self.assertEqual(revoke_response.status_code, 200) + cert.refresh_from_db() + self.assertEqual(cert.revoked, True) + self.assertIn(cert, list(ca.get_revoked_certs())) + + def test_cert_renew_api_disabled_org(self): + org = self._get_org() + ca = self._create_ca(organization=org) + cert = self._create_cert(ca=ca, organization=org) + serial_number = str(cert.serial_number) + org.is_active = False + org.save(update_fields=["is_active"]) + renew_path = reverse("pki_api:cert_renew", args=[cert.pk]) + renew_response = self.client.post(renew_path) + self.assertEqual(renew_response.status_code, 403) + cert.refresh_from_db() + self.assertEqual(cert.revoked, False) + self.assertEqual(cert.serial_number, serial_number) + self.assertEqual(Cert.objects.count(), 1) + + def test_cert_disabled_org_api_crud(self): + org = self._create_org(name="disabled-org", slug="disabled-org") + ca = self._create_ca(name="disabled-ca", organization=org) + cert = self._create_cert(name="disabled-cert", ca=ca, organization=org) + org.is_active = False + org.save(update_fields=["is_active"]) + create_payload = self._cert_data + create_payload["organization"] = str(org.pk) + create_payload["ca"] = ca.pk + self._test_disabled_org_api_crud( + cert, + detail_url=reverse("pki_api:cert_detail", args=[cert.pk]), + list_url=reverse("pki_api:cert_list"), + create_payload=create_payload, + update_payload={"name": "renamed-cert"}, + ) + def test_cert_post_with_date_none(self): path = reverse("pki_api:cert_list") data = { diff --git a/openwisp_controller/subnet_division/tasks.py b/openwisp_controller/subnet_division/tasks.py index 998e9a1c2..e57f3f417 100644 --- a/openwisp_controller/subnet_division/tasks.py +++ b/openwisp_controller/subnet_division/tasks.py @@ -88,14 +88,21 @@ def _create_ipaddress_and_subnetdivision_index_objects(ips, indexes): generated_indexes = [] try: - division_rule = SubnetDivisionRule.objects.get(id=rule_id) + division_rule = SubnetDivisionRule.objects.select_related("organization").get( + id=rule_id + ) except SubnetDivisionRule.DoesNotExist as e: logger.warning( "Failed to provision extra IPs for Subnet Division Rule " f'with id: "{rule_id}", reason: {e}' ) return - + if division_rule.organization_id and not division_rule.organization.is_active: + logger.info( + "Skipping extra IP provisioning for rule %s of disabled organization", + rule_id, + ) + return index_queryset = division_rule.subnetdivisionindex_set.filter( subnet_id__isnull=False, config_id__isnull=False, @@ -134,12 +141,17 @@ def _create_ipaddress_and_subnetdivision_index_objects(ips, indexes): @shared_task def provision_subnet_ip_for_existing_devices(rule_id): try: - rule = SubnetDivisionRule.objects.get(id=rule_id) + rule = SubnetDivisionRule.objects.select_related("organization").get(id=rule_id) except SubnetDivisionRule.DoesNotExist as error: logger.warning( "Failed to provision IPs on existing devices for Subnet " f'Division Rule with id: "{rule_id}", reason: {error}' ) return - else: - rule.rule_class.provision_for_existing_objects(rule) + if rule.organization_id and not rule.organization.is_active: + logger.info( + "Skipping subnet provisioning for rule %s of disabled organization", + rule_id, + ) + return + rule.rule_class.provision_for_existing_objects(rule) diff --git a/openwisp_controller/subnet_division/tests/test_models.py b/openwisp_controller/subnet_division/tests/test_models.py index 071fb576f..58a1acee2 100644 --- a/openwisp_controller/subnet_division/tests/test_models.py +++ b/openwisp_controller/subnet_division/tests/test_models.py @@ -680,6 +680,61 @@ def test_sharable_vpn_vpnclient_subnet_multiple_rules(self): self.assertNotIn(config1_subnets.first(), config2_subnets) self.assertNotIn(config1_subnets.last(), config2_subnets) + def test_provision_subnet_ip_skips_disabled_org(self): + org = self._create_org(name="disabled-org", slug="disabled-org") + rule = self._get_vpn_subdivision_rule( + organization=org, + master_subnet=self._get_master_subnet( + subnet="10.200.0.0/16", organization=org + ), + ) + org.is_active = False + org.save(update_fields=["is_active"]) + with ( + patch( + "openwisp_controller.subnet_division.tasks.logger.info" + ) as mocked_logger, + patch.object(rule.rule_class, "provision_for_existing_objects") as mocked, + ): + tasks.provision_subnet_ip_for_existing_devices.run(rule.id) + mocked_logger.assert_called_once_with( + "Skipping subnet provisioning for rule %s of disabled organization", + rule.id, + ) + mocked.assert_not_called() + + def test_provision_extra_ips_skips_disabled_org(self): + org = self._create_org(name="disabled-org", slug="disabled-org") + master_subnet = self._get_master_subnet( + subnet="10.201.0.0/16", organization=org + ) + rule = self._get_vpn_subdivision_rule( + organization=org, master_subnet=master_subnet + ) + config = self._create_config(organization=org) + subnet = self._create_subnet( + subnet="10.201.0.0/28", organization=org, master_subnet=master_subnet + ) + SubnetDivisionIndex.objects.create( + keyword="OW_subnet0", + subnet=subnet, + rule=rule, + config=config, + ) + old_number_of_ips = rule.number_of_ips + ip_count_before = IpAddress.objects.count() + index_count_before = SubnetDivisionIndex.objects.count() + org.is_active = False + org.save(update_fields=["is_active"]) + with patch("openwisp_controller.subnet_division.tasks.logger.info") as mocked: + tasks.provision_extra_ips.run(rule.id, old_number_of_ips=old_number_of_ips) + mocked.assert_called_once_with( + "Skipping extra IP provisioning for rule %s of disabled organization", + rule.id, + ) + self.assertEqual(IpAddress.objects.count(), ip_count_before) + self.assertEqual(SubnetDivisionIndex.objects.count(), index_count_before) + def test_device_deleted(self): rule = self._get_vpn_subdivision_rule() subnet_query = self.subnet_query.filter(organization_id=self.org.id).exclude( diff --git a/openwisp_controller/tests/test_users_integration.py b/openwisp_controller/tests/test_users_integration.py index 4481ab32d..a58931823 100644 --- a/openwisp_controller/tests/test_users_integration.py +++ b/openwisp_controller/tests/test_users_integration.py @@ -1,3 +1,4 @@ +from openwisp_controller.config.admin import OrganizationLimitsInline from openwisp_users.tests.test_admin import TestUsersAdmin from .mixins import GetEditFormInlineMixin @@ -10,5 +11,14 @@ class TestUsersIntegration(GetEditFormInlineMixin, TestUsersAdmin): is_integration_test = True + def _get_disabled_org_test_excluded_inline(self): + inlines = super()._get_disabled_org_test_excluded_inline() + # The shared disabled-org test helper asserts has_delete_permission + # stays True for every inline on a disabled organization, but this + # inline's has_delete_permission always returns False regardless of + # organization status, so it must be excluded from that assertion. + inlines += [OrganizationLimitsInline] + return inlines + del TestUsersAdmin diff --git a/tests/openwisp2/sample_users/migrations/0006_user_password_based_token.py b/tests/openwisp2/sample_users/migrations/0006_user_password_based_token.py index f689d67d2..c48d7f94b 100644 --- a/tests/openwisp2/sample_users/migrations/0006_user_password_based_token.py +++ b/tests/openwisp2/sample_users/migrations/0006_user_password_based_token.py @@ -1,6 +1,7 @@ -# Generated by Django 5.2.13 on 2026-08-14 20:35 +# Generated by Django 5.2.16 on 2026-07-29 19:17 from django.db import migrations, models +from django.utils.translation import gettext_lazy as _ class Migration(migrations.Migration): @@ -16,15 +17,16 @@ class Migration(migrations.Migration): field=models.BooleanField( blank=True, default=None, - help_text=( - "Indicates whether the last authentication token was obtained " - "using the local password. When false, the token came from an " - "external method (eg: SSO, SAML) and password expiration is not " - "enforced for it. None means no token has been issued for this " - "user since this feature was introduced." + help_text=_( + "Indicates whether the last authentication token was" + " obtained using the local password. When false, the" + " token came from an external method (eg: SSO, SAML)" + " and password expiration is not enforced for it. None" + " means no token has been issued for this user since" + " this feature was introduced." ), null=True, - verbose_name="password based token", + verbose_name=_("password based token"), ), ), ] diff --git a/tests/openwisp2/sample_users/tests.py b/tests/openwisp2/sample_users/tests.py index cfee27509..554eb8e75 100644 --- a/tests/openwisp2/sample_users/tests.py +++ b/tests/openwisp2/sample_users/tests.py @@ -1,13 +1,15 @@ from unittest.mock import patch from openwisp_controller.tests.mixins import GetEditFormInlineMixin +from openwisp_controller.tests.test_users_integration import ( + TestUsersIntegration as BaseTestUsersAdmin, +) from openwisp_users.tests.test_admin import ( TestBasicUsersIntegration as BaseTestBasicUsersIntegration, ) from openwisp_users.tests.test_admin import ( TestMultitenantAdmin as BaseTestMultitenantAdmin, ) -from openwisp_users.tests.test_admin import TestUsersAdmin as BaseTestUsersAdmin from openwisp_users.tests.test_models import TestUsers as BaseTestUsers additional_fields = [ @@ -15,9 +17,8 @@ ] -class TestUsersAdmin(GetEditFormInlineMixin, BaseTestUsersAdmin): +class TestUsersAdmin(BaseTestUsersAdmin): app_label = "sample_users" - is_integration_test = True _additional_user_fields = additional_fields