From 169aea08337dcdd6f4be4cd8b2708301960d7c96 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Wed, 5 Aug 2026 17:01:12 +0530 Subject: [PATCH 01/21] [change] Limit controller operations on disabled organizations #1393 Closes #1393 --- openwisp_controller/config/admin.py | 52 +++++- openwisp_controller/config/api/serializers.py | 9 +- .../config/controller/views.py | 2 + openwisp_controller/config/exportable.py | 13 +- openwisp_controller/config/handlers.py | 13 +- openwisp_controller/config/tasks.py | 31 +++- .../config/tests/test_admin.py | 156 +++++++++++++++++- openwisp_controller/config/tests/test_api.py | 149 +++++++++++++++-- .../config/tests/test_controller.py | 94 ++++++----- .../config/tests/test_handlers.py | 49 ++++-- openwisp_controller/config/tests/test_vpn.py | 13 ++ openwisp_controller/config/whois/tasks.py | 2 + .../config/whois/tests/tests.py | 12 ++ .../connection/api/serializers.py | 2 +- openwisp_controller/connection/api/views.py | 6 +- openwisp_controller/geo/api/views.py | 8 +- .../geo/estimated_location/tasks.py | 6 +- .../geo/estimated_location/tests/tests.py | 13 ++ openwisp_controller/mixins.py | 7 +- openwisp_controller/pki/admin.py | 35 +++- openwisp_controller/pki/tests/test_admin.py | 46 ++++++ openwisp_controller/subnet_division/tasks.py | 11 +- .../subnet_division/tests/test_models.py | 17 ++ 23 files changed, 662 insertions(+), 84 deletions(-) diff --git a/openwisp_controller/config/admin.py b/openwisp_controller/config/admin.py index b82f65ece..94aa838d1 100644 --- a/openwisp_controller/config/admin.py +++ b/openwisp_controller/config/admin.py @@ -601,7 +601,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 +726,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 +739,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 +876,29 @@ 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).iterator() + ) + 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): @@ -1178,11 +1208,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}" ) + 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): logger.warning( @@ -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/controller/views.py b/openwisp_controller/config/controller/views.py index 2a852e3d2..36a41b1e4 100644 --- a/openwisp_controller/config/controller/views.py +++ b/openwisp_controller/config/controller/views.py @@ -417,6 +417,8 @@ def post(self, request, *args, **kwargs): device = self.model.objects.select_related("config").get(key=key) if device.is_deactivated(): return ControllerResponse("error: device deactivated", 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..905de4588 100644 --- a/openwisp_controller/config/exportable.py +++ b/openwisp_controller/config/exportable.py @@ -1,7 +1,7 @@ import json import uuid -from django.core.exceptions import ObjectDoesNotExist +from django.core.exceptions import ObjectDoesNotExist, ValidationError from import_export import resources, widgets from import_export.fields import Field from swapper import load_model @@ -11,6 +11,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 +121,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..b1a848a45 100644 --- a/openwisp_controller/config/handlers.py +++ b/openwisp_controller/config/handlers.py @@ -189,7 +189,10 @@ def devicegroup_templates_change_handler(instance, **kwargs): def organization_disabled_handler(instance, **kwargs): """ - Asynchronously invalidates device and VPN controller views cache + Asynchronously deactivates devices and invalidates controller view caches + when an organization transitions from active to inactive. + + Re-enabling an organization triggers no device reactivation. """ if instance.is_active: return @@ -200,4 +203,10 @@ def organization_disabled_handler(instance, **kwargs): 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.id) + + def _on_commit(): + tasks.deactivate_organization_devices.delay(organization_id) + tasks.invalidate_controller_views_cache.delay(organization_id) + + transaction.on_commit(_on_commit) diff --git a/openwisp_controller/config/tasks.py b/openwisp_controller/config/tasks.py index abf51b310..fe7f83960 100644 --- a/openwisp_controller/config/tasks.py +++ b/openwisp_controller/config/tasks.py @@ -126,10 +126,16 @@ def invalidate_devicegroup_cache_delete(instance_id, model_name, **kwargs): def trigger_vpn_server_endpoint(endpoint, auth_token, vpn_id): Vpn = load_model("config", "Vpn") try: - vpn = Vpn.objects.get(pk=vpn_id) + vpn = Vpn.objects.select_related("organization").get(pk=vpn_id) except Vpn.DoesNotExist: logger.error(f"VPN Server UUID: {vpn_id} does not exist.") return + if vpn.organization_id and not vpn.organization.is_active: + logger.info( + "Skipping update webhook for VPN Server UUID: %s of disabled organization", + vpn_id, + ) + return # Cache the configuration here makes downloading the configuration faster. vpn.get_cached_configuration() @@ -218,3 +224,26 @@ 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") + 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..97e21df4f 100644 --- a/openwisp_controller/config/tests/test_admin.py +++ b/openwisp_controller/config/tests/test_admin.py @@ -119,6 +119,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 = ( @@ -666,6 +685,45 @@ 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, "Selected organization is disabled.") + 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, "Cannot activate devices of a disabled organization" + ) + 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 +1003,102 @@ 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"), + ) + 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"} + ) + + 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"} + ) + + 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"}) + + 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 = 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 +2443,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 diff --git a/openwisp_controller/config/tests/test_api.py b/openwisp_controller/config/tests/test_api.py index e5f2136a1..85d9fe333 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,108 @@ 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] + ) + 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, 403) + + 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_controller.py b/openwisp_controller/config/tests/test_controller.py index 2fb85fe2a..1526a427a 100644 --- a/openwisp_controller/config/tests/test_controller.py +++ b/openwisp_controller/config/tests/test_controller.py @@ -63,17 +63,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 +78,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 +269,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 +333,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 +1221,23 @@ 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"]) + response = self.client.post( + self.register_url, + self._get_reregistration_payload(device, name=TEST_MACADDR_NAME), ) - self.assertEqual(response.status_code, 404) + 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 +1436,38 @@ 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() + # Device can fetch checksum untill the device is deactivated + response = self.client.get( + reverse("controller:device_checksum", args=[device.pk]), + {"key": device.key}, + ) + self.assertEqual(response.status_code, 404) + + 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_handlers.py b/openwisp_controller/config/tests/test_handlers.py index de4981224..f046be2e2 100644 --- a/openwisp_controller/config/tests/test_handlers.py +++ b/openwisp_controller/config/tests/test_handlers.py @@ -1,37 +1,64 @@ from unittest.mock import patch -from django.test import TestCase - -from openwisp_users.tests.utils import TestOrganizationMixin +from django.test import TransactionTestCase from .. import tasks +from .utils import CreateConfigMixin -class TestHandlers(TestOrganizationMixin, TestCase): +class TestHandlers(CreateConfigMixin, TransactionTestCase): + @patch.object(tasks.deactivate_organization_devices, "delay") @patch.object(tasks.invalidate_controller_views_cache, "delay") - def test_organization_disabled_handler(self, mocked_task): + def test_organization_disabled_handler(self, mocked_invalidate, mocked_deactivate): with self.subTest("Test task not executed on creating active orgs"): org = self._create_org() - mocked_task.assert_not_called() + mocked_invalidate.assert_not_called() + mocked_deactivate.assert_not_called() with self.subTest("Test task executed on changing active to inactive org"): org.is_active = False org.save() - mocked_task.assert_called_once_with(str(org.id)) + mocked_invalidate.assert_called_once_with(str(org.id)) + mocked_deactivate.assert_called_once_with(str(org.id)) - mocked_task.reset_mock() + mocked_invalidate.reset_mock() + mocked_deactivate.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_invalidate.assert_not_called() + mocked_deactivate.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() + mocked_invalidate.assert_not_called() + mocked_deactivate.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() + mocked_invalidate.assert_not_called() + mocked_deactivate.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.assertEqual(config.status in ("deactivating", "deactivated"), True) + + 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) diff --git a/openwisp_controller/config/tests/test_vpn.py b/openwisp_controller/config/tests/test_vpn.py index e1611e740..ab3ae6942 100644 --- a/openwisp_controller/config/tests/test_vpn.py +++ b/openwisp_controller/config/tests/test_vpn.py @@ -817,6 +817,19 @@ 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): + 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_not_called() + class TestWireguardTransaction(BaseTestVpn, TestWireguardVpnMixin, TransactionTestCase): mock_response = mock.Mock(spec=requests.Response) diff --git a/openwisp_controller/config/whois/tasks.py b/openwisp_controller/config/whois/tasks.py index b95803025..6a49b0ff5 100644 --- a/openwisp_controller/config/whois/tasks.py +++ b/openwisp_controller/config/whois/tasks.py @@ -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 @@ -92,6 +93,7 @@ def fetch_whois_details(self, device_pk, initial_ip_address): 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..8863f95fa 100644 --- a/openwisp_controller/connection/api/serializers.py +++ b/openwisp_controller/connection/api/serializers.py @@ -21,7 +21,7 @@ def validate(self, data): return super().validate(data) -class CommandSerializer(ValidatedDeviceFieldSerializer): +class CommandSerializer(ValidatedDeviceFieldSerializer, FilterSerializerByOrgManaged): 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..1cd49a705 100644 --- a/openwisp_controller/connection/api/views.py +++ b/openwisp_controller/connection/api/views.py @@ -41,7 +41,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 ( @@ -127,7 +127,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/geo/api/views.py b/openwisp_controller/geo/api/views.py index 9650d8230..5ed59a1a8 100644 --- a/openwisp_controller/geo/api/views.py +++ b/openwisp_controller/geo/api/views.py @@ -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: @@ -173,7 +175,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() diff --git a/openwisp_controller/geo/estimated_location/tasks.py b/openwisp_controller/geo/estimated_location/tasks.py index 1ca233024..6f63871f6 100644 --- a/openwisp_controller/geo/estimated_location/tasks.py +++ b/openwisp_controller/geo/estimated_location/tasks.py @@ -29,7 +29,11 @@ def manage_estimated_locations(device_pk, ip_address): .select_related("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/mixins.py b/openwisp_controller/mixins.py index 2e148772a..5908031df 100644 --- a/openwisp_controller/mixins.py +++ b/openwisp_controller/mixins.py @@ -1,6 +1,10 @@ from openwisp_users.api.mixins import FilterByOrganizationManaged, FilterByParentManaged from openwisp_users.api.mixins import ProtectedAPIMixin as BaseProtectedAPIMixin -from openwisp_users.api.permissions import DjangoModelPermissions, IsOrganizationManager +from openwisp_users.api.permissions import ( + DisabledOrgReadOnly, + DjangoModelPermissions, + IsOrganizationManager, +) class RelatedDeviceModelPermission(DjangoModelPermissions): @@ -28,6 +32,7 @@ class RelatedDeviceProtectedAPIMixin(FilterByParentManaged, BaseProtectedAPIMixi permission_classes = [ IsOrganizationManager, RelatedDeviceModelPermission, + DisabledOrgReadOnly, ] diff --git a/openwisp_controller/pki/admin.py b/openwisp_controller/pki/admin.py index 90e7882e1..58317fb44 100644 --- a/openwisp_controller/pki/admin.py +++ b/openwisp_controller/pki/admin.py @@ -1,4 +1,7 @@ -from django.contrib import admin +from django.contrib import admin, messages +from django.contrib.admin import action +from django.db.models import Q +from django.utils.translation import gettext_lazy as _ from django_x509.base.admin import AbstractCaAdmin, AbstractCertAdmin from reversion.admin import VersionAdmin from swapper import load_model @@ -11,10 +14,30 @@ Cert = load_model("django_x509", "Cert") +def _exclude_disabled_org(self, request, queryset): + allowed = queryset.filter( + Q(organization__isnull=True) | Q(organization__is_active=True) + ) + skipped = queryset.count() - allowed.count() + if skipped: + self.message_user( + request, + _("%d item(s) belonging to a disabled organization were skipped.") + % skipped, + level=messages.WARNING, + ) + return allowed + + @admin.register(Ca) class CaAdmin(MultitenantAdminMixin, AbstractCaAdmin, VersionAdmin): history_latest_first = True + @action(description=_("Renew selected CAs"), permissions=["change"]) + def renew_ca(self, request, queryset): + queryset = _exclude_disabled_org(self, request, queryset) + return super().renew_ca(request, queryset) + CaAdmin.fields.insert(2, "organization") CaAdmin.list_filter.insert(0, MultitenantOrgFilter) @@ -27,6 +50,16 @@ class CertAdmin(MultitenantAdminMixin, AbstractCertAdmin, VersionAdmin): multitenant_shared_relations = ("ca",) history_latest_first = True + @action(description=_("Renew selected certificates"), permissions=["change"]) + def renew_cert(self, request, queryset): + queryset = _exclude_disabled_org(self, request, queryset) + return super().renew_cert(request, queryset) + + @action(description=_("Revoke selected certificates"), permissions=["change"]) + def revoke_action(self, request, queryset): + queryset = _exclude_disabled_org(self, request, queryset) + return super().revoke_action(request, queryset) + CertAdmin.fields.insert(2, "organization") CertAdmin.list_filter.insert(0, MultitenantOrgFilter) diff --git a/openwisp_controller/pki/tests/test_admin.py b/openwisp_controller/pki/tests/test_admin.py index a12deb1f1..ddb15afe0 100644 --- a/openwisp_controller/pki/tests/test_admin.py +++ b/openwisp_controller/pki/tests/test_admin.py @@ -144,3 +144,49 @@ def test_admin_menu_groups(self): '
Cas & Certificates
', html=True, ) + + def test_ca_renew_action_skips_disabled_org(self): + org = self._get_org() + ca = self._create_ca(name="ca-disabled", organization=org) + old_serial = ca.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() + self.assertEqual(str(ca.serial_number), str(old_serial)) + self.assertContains( + response, "1 item(s) belonging to a disabled organization were skipped." + ) + + def test_cert_actions_skip_disabled_org(self): + 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]} + response = self.client.post(changelist, revoke_payload, follow=True) + cert.refresh_from_db() + self.assertEqual(cert.revoked, False) + self.assertContains( + response, "1 item(s) belonging to a disabled organization were skipped." + ) + 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, "1 item(s) belonging to a disabled organization were skipped." + ) diff --git a/openwisp_controller/subnet_division/tasks.py b/openwisp_controller/subnet_division/tasks.py index 998e9a1c2..b7f510e60 100644 --- a/openwisp_controller/subnet_division/tasks.py +++ b/openwisp_controller/subnet_division/tasks.py @@ -134,12 +134,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..9a45995c3 100644 --- a/openwisp_controller/subnet_division/tests/test_models.py +++ b/openwisp_controller/subnet_division/tests/test_models.py @@ -679,6 +679,23 @@ def test_sharable_vpn_vpnclient_subnet_multiple_rules(self): ).values_list("subnet__subnet", flat=True) 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: + tasks.provision_subnet_ip_for_existing_devices.run(rule.id) + mocked.assert_called_once_with( + "Skipping subnet provisioning for rule %s of disabled organization", + rule.id, + ) def test_device_deleted(self): rule = self._get_vpn_subdivision_rule() From e7bfea8efec2f2514d4d8b0f28071c46ce71d909 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Wed, 5 Aug 2026 18:05:27 +0530 Subject: [PATCH 02/21] [ci] Install openwisp-users from GitHub branch --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 062131170..99c14f61f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,6 +72,7 @@ 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 ${{ matrix.django-version }} - name: Start redis From 9ec467f484379412c10d3b38655cbd833ae9185b Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Wed, 5 Aug 2026 22:19:34 +0530 Subject: [PATCH 03/21] [tests] Fixes tests --- openwisp_controller/config/admin.py | 11 +- .../connection/tests/test_admin.py | 39 +++++ .../connection/tests/test_api.py | 94 +++++++++++- openwisp_controller/geo/tests/test_admin.py | 124 +++++++++++++++ openwisp_controller/geo/tests/test_api.py | 144 ++++++++++++++++++ openwisp_controller/pki/tests/test_admin.py | 39 +++++ openwisp_controller/pki/tests/test_api.py | 73 ++++++++- .../subnet_division/tests/test_models.py | 2 +- 8 files changed, 515 insertions(+), 11 deletions(-) diff --git a/openwisp_controller/config/admin.py b/openwisp_controller/config/admin.py index 94aa838d1..ae22d5fbd 100644 --- a/openwisp_controller/config/admin.py +++ b/openwisp_controller/config/admin.py @@ -1006,15 +1006,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( @@ -1023,7 +1026,7 @@ def get_extra_context(self, pk=None): ) } ) - else: + elif not device.is_deactivated(): ctx["additional_buttons"].append( { "raw_html": mark_safe( diff --git a/openwisp_controller/connection/tests/test_admin.py b/openwisp_controller/connection/tests/test_admin.py index 379346bc2..37d558044 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 @@ -97,6 +99,25 @@ def test_connection_credentials_fk_queryset(self): select_widget=True, ) + 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"} + ) + + 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): widget = CredentialsSchemaWidget() html = widget.media.render() @@ -236,6 +257,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..769a424c8 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,35 @@ 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, 201) + + 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, + operations=("list", "retrieve"), + organization=org, + ) + # for ensuring that only related connections are shown def test_available_connections(self): device = self._create_device( @@ -425,12 +456,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") @@ -565,6 +619,40 @@ 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, 400) + + 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) + ) + self._test_disabled_org_api_crud( + dc, + detail_url=detail_url, + list_url=list_url, + update_payload={"enabled": False}, + unchanged_field="enabled", + operations=("list", "retrieve", "update", "delete"), + organization=org, + ) + def test_post_deviceconenction_with_no_config_device(self): d1 = self._create_device() path = reverse("connection_api:deviceconnection_list", args=(d1.pk,)) diff --git a/openwisp_controller/geo/tests/test_admin.py b/openwisp_controller/geo/tests/test_admin.py index 8fd113cad..9fe50603c 100644 --- a/openwisp_controller/geo/tests/test_admin.py +++ b/openwisp_controller/geo/tests/test_admin.py @@ -1,5 +1,6 @@ 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 @@ -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 from .utils import TestGeoMixin Device = load_model("config", "Device") @@ -129,6 +131,51 @@ 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"} + ) + + 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" + ) + + 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 @@ -238,6 +285,41 @@ def test_non_estimated_location_warning(self): "estimated flag.", ) + def test_device_disabled_org_admin_inline_readonly(self): + active_org = self._get_org(org_name="default") + active_location = self._create_location( + name="active-location", type="indoor", organization=active_org + ) + active_device = self._create_object( + name="active-device", + organization=active_org, + mac_address="00:11:22:33:44:70", + ) + self._create_object_location( + location=active_location, content_object=active_device + ) + disabled_org = self._create_organization( + name="device-disabled-org", is_active=False + ) + disabled_location = self._create_location( + name="disabled-location", type="indoor", organization=disabled_org + ) + disabled_device = self._create_object( + name="disabled-device", + organization=disabled_org, + mac_address="00:11:22:33:44:71", + ) + self._create_object_location( + location=disabled_location, content_object=disabled_device + ) + model_admin = admin.site._registry[Device] + self._test_disabled_org_admin_inline_readonly( + model_admin, + disabled_device, + active_obj=active_device, + inline_models=(DeviceLocationInline,), + ) + def test_device_export_geo(self): org = self._get_org(org_name="default") location = self._create_location( @@ -275,6 +357,48 @@ def test_device_export_geo(self): contents, ) + def test_device_export_geo_disabled_org(self): + org = self._get_org(org_name="default") + location = self._create_location( + name="disabled-org-location", type="indoor", organization=org + ) + floorplan = self._create_floorplan(location=location, organization=org) + device = self._create_object( + name="disabled-org-device", + organization=org, + mac_address="00:11:22:33:44:67", + ) + self._create_object_location( + location=location, floorplan=floorplan, content_object=device + ) + org.is_active = False + org.save(update_fields=["is_active"]) + response = self.client.post( + reverse(f"admin:{self.app_label}_device_export"), {"format": "0"} + ) + self.assertEqual(response.status_code, 200) + self.assertIn("disabled-org-device", response.content.decode("utf-8")) + + def test_device_import_geo_disabled_org_rejected(self): + org = self._get_org(org_name="default") + location = self._create_location( + name="disabled-org-location", type="indoor", organization=org + ) + org.is_active = False + org.save(update_fields=["is_active"]) + contents = ( + "name,mac_address,organization_id,location_id\n" + f"disabled-import,00:11:22:33:44:68,{org.pk},{location.pk}" + ) + csv = ContentFile(contents) + response = self.client.post( + reverse(f"admin:{self.app_label}_device_import"), + {"format": "0", "import_file": csv, "file_name": "test.csv"}, + ) + self.assertNotIn("confirm_form", response.context) + self.assertContains(response, "Cannot import rows for disabled organizations.") + self.assertEqual(Device.objects.filter(name="disabled-import").exists(), False) + def test_device_import_geo(self): org = self._get_org(org_name="default") location = self._create_location( diff --git a/openwisp_controller/geo/tests/test_api.py b/openwisp_controller/geo/tests/test_api.py index 156550692..7bbc367af 100644 --- a/openwisp_controller/geo/tests/test_api.py +++ b/openwisp_controller/geo/tests/test_api.py @@ -20,6 +20,7 @@ CreateDeviceMixin, ) from openwisp_controller.tests.utils import TestAdminMixin +from openwisp_users.tests.test_api import AuthenticationMixin, TestDisabledOrgApiMixin from openwisp_utils.tests import AssertNumQueriesSubTestMixin, capture_any_output from .utils import TestGeoMixin @@ -199,6 +200,26 @@ def test_deactivated_device(self): ) self.assertEqual(response.status_code, 403) + def test_disabled_org_device(self): + device = self._create_object_location().device + url = "{0}?key={1}".format(reverse(self.url_name, args=[device.pk]), device.key) + device.organization.is_active = False + device.organization.save(update_fields=["is_active"]) + + with self.subTest("Test retrieving device coordinates"): + response = self.client.get( + url, + content_type="application/json", + ) + self.assertEqual(response.status_code, 200) + + with self.subTest("Test updating device coordinates"): + response = self.client.put( + url, + content_type="application/json", + ) + self.assertEqual(response.status_code, 403) + class TestMultitenantApi(TestGeoMixin, TestCase, CreateConfigTemplateMixin): object_location_model = DeviceLocation @@ -394,6 +415,8 @@ class TestGeoApi( TestGeoMixin, TestAdminMixin, CreateDeviceMixin, + TestDisabledOrgApiMixin, + AuthenticationMixin, TestCase, ): object_model = Device @@ -522,6 +545,42 @@ def test_delete_floorplan_detail(self): response = self.client.delete(path) self.assertEqual(response.status_code, 204) + def test_floorplan_disabled_org_api_crud(self): + org = self._create_org(name="disabled-org", slug="disabled-org") + location = self._create_location( + name="disabled-location", type="indoor", organization=org + ) + floorplan = self._create_floorplan(location=location, organization=org) + org.is_active = False + org.save(update_fields=["is_active"]) + self._test_disabled_org_api_crud( + floorplan, + detail_url=reverse("geo_api:detail_floorplan", args=[floorplan.pk]), + list_url=reverse("geo_api:list_floorplan"), + update_payload={"floor": 2}, + unchanged_field="floor", + operations=("list", "retrieve", "update", "delete"), + ) + + def test_post_floorplan_list_disabled_org(self): + org = self._create_org(name="disabled-org", slug="disabled-org") + location = self._create_location( + name="disabled-location", type="indoor", organization=org + ) + org.is_active = False + org.save(update_fields=["is_active"]) + path = reverse("geo_api:list_floorplan") + data = { + "floor": 1, + "image": self._get_simpleuploadedfile(), + "location": location.pk, + } + response = self.client.post(path, data, format="multipart") + # blocked incidentally: FilterSerializerByOrgManaged excludes the + # disabled organization's location from the "location" field + # queryset, not by an explicit disabled-org check on this endpoint + self.assertEqual(response.status_code, 400) + def test_get_location_list(self): path = reverse("geo_api:list_location") with self.assertNumQueries(2): @@ -650,6 +709,67 @@ def test_patch_location_detail(self): self.assertEqual(response.status_code, 200) self.assertEqual(response.data["name"], "change-test-location") + def test_location_disabled_org_api_crud(self): + org = self._create_org(name="disabled-org", slug="disabled-org") + location = self._create_location( + name="disabled-location", type="indoor", organization=org + ) + org.is_active = False + org.save(update_fields=["is_active"]) + coords = json.loads(Point(2, 23).geojson) + create_payload = { + "organization": str(org.pk), + "name": "new-location", + "type": "outdoor", + "is_mobile": False, + "address": "Via del Corso, Roma, Italia", + "geometry": coords, + } + self._test_disabled_org_api_crud( + location, + detail_url=reverse("geo_api:detail_location", args=[location.pk]), + list_url=reverse("geo_api:list_location"), + create_payload=create_payload, + update_payload={"name": "renamed-location"}, + ) + + def test_organization_geo_settings_disabled_org(self): + org = self._create_org(name="disabled-org", slug="disabled-org") + geo_settings = OrganizationGeoSettings.objects.get(organization=org) + org.is_active = False + org.save(update_fields=["is_active"]) + url = reverse("geo_api:organization_geo_settings", args=[org.pk]) + auth = self._disabled_org_api_auth(self._get_admin()) + self._test_disabled_org_api_retrieve(url, auth, status=200) + self._test_disabled_org_api_update( + url, + auth, + {"estimated_location_enabled": False}, + geo_settings, + unchanged_field="estimated_location_enabled", + ) + + def test_location_geojson_and_device_list_disabled_org(self): + org = self._create_org(name="disabled-org", slug="disabled-org") + location = self._create_location( + name="disabled-location", type="indoor", organization=org + ) + device = self._create_object( + name="disabled-device", organization=org, mac_address="00:11:22:33:44:69" + ) + self._create_object_location(location=location, content_object=device) + org.is_active = False + org.save(update_fields=["is_active"]) + with self.subTest("location_geojson stays readable"): + response = self.client.get(reverse("geo_api:location_geojson")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, str(location.id)) + with self.subTest("location_device_list stays readable"): + response = self.client.get( + reverse("geo_api:location_device_list", args=[location.id]) + ) + self.assertEqual(response.status_code, 200) + def test_create_location_outdoor_with_floorplan(self): path = reverse("geo_api:list_location") coords = json.loads(Point(2, 23).geojson) @@ -855,6 +975,30 @@ def test_create_devicelocation_using_related_ids(self): self.assertEqual(self.location_model.objects.count(), 1) self.assertEqual(self.floorplan_model.objects.count(), 1) + def test_create_devicelocation_disabled_org(self): + # a PUT-as-create at this parent-device-scoped endpoint is checked + # via clone_request(..., "POST") -> has_permission, not + # has_object_permission: RelatedDeviceModelPermission only checks + # device.is_deactivated(), and DisabledOrgReadOnly has no + # has_permission, so a disabled organization does not currently + # block creating a DeviceLocation this way + device = self._create_object() + floorplan = self._create_floorplan() + location = floorplan.location + device.organization.is_active = False + device.organization.save(update_fields=["is_active"]) + url = reverse("geo_api:device_location", args=[device.id]) + response = self.client.put( + url, + data={ + "location": location.id, + "floorplan": floorplan.id, + "indoor": "12.342,23.541", + }, + content_type="application/json", + ) + self.assertEqual(response.status_code, 201) + def test_create_devicelocation_location_floorplan(self): device = self._create_object() self.assertEqual(self.location_model.objects.count(), 0) diff --git a/openwisp_controller/pki/tests/test_admin.py b/openwisp_controller/pki/tests/test_admin.py index ddb15afe0..e87774adf 100644 --- a/openwisp_controller/pki/tests/test_admin.py +++ b/openwisp_controller/pki/tests/test_admin.py @@ -145,10 +145,44 @@ def test_admin_menu_groups(self): 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"}) + + 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"}) + + 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") @@ -159,12 +193,17 @@ def test_ca_renew_action_skips_disabled_org(self): } 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, "1 item(s) belonging to a disabled organization were skipped." ) def test_cert_actions_skip_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) diff --git a/openwisp_controller/pki/tests/test_api.py b/openwisp_controller/pki/tests/test_api.py index 51faebdaf..481323af4 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, ): @@ -161,13 +161,49 @@ def test_ca_post_renew_api(self): ca1 = self._create_ca(name="ca1", organization=self._get_org()) old_serial_num = ca1.serial_number path = reverse("pki_api:ca_renew", args=[ca1.pk]) - with self.assertNumQueries(5): + with self.assertNumQueries(6): r = self.client.post(path) ca1.refresh_from_db() self.assertEqual(r.status_code, 200) 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,37 @@ def test_cert_post_with_extensions_field(self): self.assertEqual(Cert.objects.count(), 1) self.assertEqual(r.data["extensions"], []) + def test_cert_revoke_renew_api_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]) + renew_path = reverse("pki_api:cert_renew", args=[cert.pk]) + revoke_response = self.client.post(revoke_path) + renew_response = self.client.post(renew_path) + self.assertEqual(revoke_response.status_code, 403) + self.assertEqual(renew_response.status_code, 403) + 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/tests/test_models.py b/openwisp_controller/subnet_division/tests/test_models.py index 9a45995c3..1d0be1da4 100644 --- a/openwisp_controller/subnet_division/tests/test_models.py +++ b/openwisp_controller/subnet_division/tests/test_models.py @@ -679,7 +679,7 @@ def test_sharable_vpn_vpnclient_subnet_multiple_rules(self): ).values_list("subnet__subnet", flat=True) 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( From f18f44cd7a8fb2a7a73fcfbcc91be09d752da417 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Thu, 6 Aug 2026 00:52:35 +0530 Subject: [PATCH 04/21] [fix] Fixed failing CI --- openwisp_controller/config/admin.py | 5 +---- openwisp_controller/geo/admin.py | 9 +++++++++ openwisp_controller/geo/estimated_location/tasks.py | 2 +- openwisp_controller/pki/tests/test_api.py | 2 +- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/openwisp_controller/config/admin.py b/openwisp_controller/config/admin.py index ae22d5fbd..c01028cc4 100644 --- a/openwisp_controller/config/admin.py +++ b/openwisp_controller/config/admin.py @@ -886,10 +886,7 @@ def activate_device(self, request, queryset): self.message_user( request, mark_safe( - _( - "Cannot activate devices of a disabled organization:" - " %(devices)s" - ) + _("Cannot activate devices of a disabled organization: %(devices)s") % {"devices": devices_html} ), messages.ERROR, diff --git a/openwisp_controller/geo/admin.py b/openwisp_controller/geo/admin.py index 615a34d66..cbcc8cf22 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,14 @@ 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: + 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/estimated_location/tasks.py b/openwisp_controller/geo/estimated_location/tasks.py index 6f63871f6..b5262ad43 100644 --- a/openwisp_controller/geo/estimated_location/tasks.py +++ b/openwisp_controller/geo/estimated_location/tasks.py @@ -26,7 +26,7 @@ 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 ( diff --git a/openwisp_controller/pki/tests/test_api.py b/openwisp_controller/pki/tests/test_api.py index 481323af4..c740c03af 100644 --- a/openwisp_controller/pki/tests/test_api.py +++ b/openwisp_controller/pki/tests/test_api.py @@ -124,7 +124,7 @@ def test_ca_put_api(self): path = reverse("pki_api:ca_detail", args=[ca1.pk]) org2 = self._create_org() data = {"name": "change-ca1", "organization": org2.pk, "notes": "change-notes"} - with self.assertNumQueries(6): + with self.assertNumQueries(7): r = self.client.put(path, data, content_type="application/json") self.assertEqual(r.status_code, 200) self.assertEqual(r.data["name"], "change-ca1") From f0c84b4775b134580c194c921ebcbf723c2ed02e Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Thu, 6 Aug 2026 01:42:54 +0530 Subject: [PATCH 05/21] [fix] Fix chained organization disable tasks --- openwisp_controller/config/handlers.py | 7 ++-- .../config/tests/test_handlers.py | 35 ++++++++----------- 2 files changed, 20 insertions(+), 22 deletions(-) diff --git a/openwisp_controller/config/handlers.py b/openwisp_controller/config/handlers.py index b1a848a45..d4b93168a 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 _ @@ -206,7 +207,9 @@ def organization_disabled_handler(instance, **kwargs): organization_id = str(instance.id) def _on_commit(): - tasks.deactivate_organization_devices.delay(organization_id) - tasks.invalidate_controller_views_cache.delay(organization_id) + chain( + tasks.deactivate_organization_devices.s(organization_id), + tasks.invalidate_controller_views_cache.si(organization_id), + ).delay() transaction.on_commit(_on_commit) diff --git a/openwisp_controller/config/tests/test_handlers.py b/openwisp_controller/config/tests/test_handlers.py index f046be2e2..49694633e 100644 --- a/openwisp_controller/config/tests/test_handlers.py +++ b/openwisp_controller/config/tests/test_handlers.py @@ -7,40 +7,35 @@ class TestHandlers(CreateConfigMixin, TransactionTestCase): - @patch.object(tasks.deactivate_organization_devices, "delay") - @patch.object(tasks.invalidate_controller_views_cache, "delay") - def test_organization_disabled_handler(self, mocked_invalidate, mocked_deactivate): + @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_invalidate.assert_not_called() - mocked_deactivate.assert_not_called() + mocked_chain.assert_not_called() with self.subTest("Test task executed on changing active to inactive org"): org.is_active = False org.save() - mocked_invalidate.assert_called_once_with(str(org.id)) - mocked_deactivate.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() - mocked_invalidate.reset_mock() - mocked_deactivate.reset_mock() + mocked_chain.reset_mock() with self.subTest("Test task not executed on saving inactive org"): org.name = "Changed named" org.save() - mocked_invalidate.assert_not_called() - mocked_deactivate.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_invalidate.assert_not_called() - mocked_deactivate.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_invalidate.assert_not_called() - mocked_deactivate.assert_not_called() + org.is_active = True + org.save() + mocked_chain.assert_not_called() def test_deactivate_organization_devices(self): org = self._create_org() From b202f1252005219d84a971fa6c1e15172ab1a776 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Thu, 6 Aug 2026 01:50:18 +0530 Subject: [PATCH 06/21] [fix] Added test for partial deactivation failure --- .../config/tests/test_handlers.py | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/openwisp_controller/config/tests/test_handlers.py b/openwisp_controller/config/tests/test_handlers.py index 49694633e..e4e190558 100644 --- a/openwisp_controller/config/tests/test_handlers.py +++ b/openwisp_controller/config/tests/test_handlers.py @@ -3,7 +3,7 @@ from django.test import TransactionTestCase from .. import tasks -from .utils import CreateConfigMixin +from .utils import CreateConfigMixin, Device class TestHandlers(CreateConfigMixin, TransactionTestCase): @@ -57,3 +57,27 @@ def test_deactivate_organization_devices(self): 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() + with patch.object(tasks, "logger") as mocked_logger: + with patch.object(Device, "deactivate", side_effect=[Exception, None]): + 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) From c06d9d7ed27f2b5b03627d65743a6e103abbf9de Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Thu, 6 Aug 2026 02:16:29 +0530 Subject: [PATCH 07/21] [fix] Fixes by @coderabbitai --- openwisp_controller/config/exportable.py | 3 ++- openwisp_controller/config/tests/test_controller.py | 1 - openwisp_controller/config/tests/test_handlers.py | 10 ++++++++-- openwisp_controller/config/whois/tasks.py | 9 +++++++-- openwisp_controller/geo/admin.py | 4 +++- openwisp_controller/geo/api/views.py | 5 ++++- openwisp_controller/geo/tests/test_api.py | 8 ++------ openwisp_controller/pki/tests/test_api.py | 9 ++++++++- 8 files changed, 34 insertions(+), 15 deletions(-) diff --git a/openwisp_controller/config/exportable.py b/openwisp_controller/config/exportable.py index 905de4588..df7d3c4c9 100644 --- a/openwisp_controller/config/exportable.py +++ b/openwisp_controller/config/exportable.py @@ -2,6 +2,7 @@ import uuid 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 @@ -129,7 +130,7 @@ def validate_instance( ).exists() ): raise ValidationError( - {"organization_id": "Cannot import rows for disabled organizations."} + {"organization_id": _("Cannot import rows for disabled organizations.")} ) if not instance._has_config(): return diff --git a/openwisp_controller/config/tests/test_controller.py b/openwisp_controller/config/tests/test_controller.py index 1526a427a..c482f2109 100644 --- a/openwisp_controller/config/tests/test_controller.py +++ b/openwisp_controller/config/tests/test_controller.py @@ -1448,7 +1448,6 @@ def test_checksum_404_disabled_org(self): self.assertEqual(response.status_code, 200) org.is_active = False org.save() - # Device can fetch checksum untill the device is deactivated response = self.client.get( reverse("controller:device_checksum", args=[device.pk]), {"key": device.key}, diff --git a/openwisp_controller/config/tests/test_handlers.py b/openwisp_controller/config/tests/test_handlers.py index e4e190558..c0e385827 100644 --- a/openwisp_controller/config/tests/test_handlers.py +++ b/openwisp_controller/config/tests/test_handlers.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import DEFAULT, patch from django.test import TransactionTestCase @@ -72,7 +72,13 @@ def test_deactivate_organization_devices_partial_failure(self): org.is_active = False org.save() with patch.object(tasks, "logger") as mocked_logger: - with patch.object(Device, "deactivate", side_effect=[Exception, None]): + with patch.object( + Device, + "deactivate", + autospec=True, + wraps=Device.deactivate, + side_effect=[Exception, DEFAULT], + ): tasks.deactivate_organization_devices(org.id) mocked_logger.exception.assert_called_once_with( "Failed to deactivate device %s while disabling organization %s", diff --git a/openwisp_controller/config/whois/tasks.py b/openwisp_controller/config/whois/tasks.py index 6a49b0ff5..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 @@ -89,7 +89,12 @@ 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() diff --git a/openwisp_controller/geo/admin.py b/openwisp_controller/geo/admin.py index cbcc8cf22..7eafa9fc4 100644 --- a/openwisp_controller/geo/admin.py +++ b/openwisp_controller/geo/admin.py @@ -50,7 +50,9 @@ class FloorPlanAdmin(MultitenantAdminMixin, AbstractFloorPlanAdmin): def get_form(self, request, obj=None, **kwargs): try: return super().get_form(request, obj, **kwargs) - except KeyError: + except KeyError as error: + if error.args != ("location",): + raise form = DjangoModelAdmin.get_form(self, request, obj, **kwargs) form._user = request.user return form diff --git a/openwisp_controller/geo/api/views.py b/openwisp_controller/geo/api/views.py index 5ed59a1a8..cc630ce8a 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): @@ -212,6 +212,9 @@ def get_object_or_none(self): # will either raise a PermissionDenied exception, or simply # return None. self.check_permissions(clone_request(self.request, "POST")) + device = self.get_parent_queryset().first() + if device and not device.organization.is_active: + raise PermissionDenied() else: # PATCH requests where the object does not exist should still # return a 404 response. diff --git a/openwisp_controller/geo/tests/test_api.py b/openwisp_controller/geo/tests/test_api.py index 7bbc367af..2cc94b914 100644 --- a/openwisp_controller/geo/tests/test_api.py +++ b/openwisp_controller/geo/tests/test_api.py @@ -769,6 +769,8 @@ def test_location_geojson_and_device_list_disabled_org(self): reverse("geo_api:location_device_list", args=[location.id]) ) self.assertEqual(response.status_code, 200) + self.assertContains(response, str(device.id)) + self.assertContains(response, device.name) def test_create_location_outdoor_with_floorplan(self): path = reverse("geo_api:list_location") @@ -976,12 +978,6 @@ def test_create_devicelocation_using_related_ids(self): self.assertEqual(self.floorplan_model.objects.count(), 1) def test_create_devicelocation_disabled_org(self): - # a PUT-as-create at this parent-device-scoped endpoint is checked - # via clone_request(..., "POST") -> has_permission, not - # has_object_permission: RelatedDeviceModelPermission only checks - # device.is_deactivated(), and DisabledOrgReadOnly has no - # has_permission, so a disabled organization does not currently - # block creating a DeviceLocation this way device = self._create_object() floorplan = self._create_floorplan() location = floorplan.location diff --git a/openwisp_controller/pki/tests/test_api.py b/openwisp_controller/pki/tests/test_api.py index c740c03af..c411add4f 100644 --- a/openwisp_controller/pki/tests/test_api.py +++ b/openwisp_controller/pki/tests/test_api.py @@ -251,14 +251,21 @@ def test_cert_revoke_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"]) revoke_path = reverse("pki_api:cert_revoke", args=[cert.pk]) renew_path = reverse("pki_api:cert_renew", args=[cert.pk]) revoke_response = self.client.post(revoke_path) - renew_response = self.client.post(renew_path) self.assertEqual(revoke_response.status_code, 403) + cert.refresh_from_db() + self.assertEqual(cert.revoked, False) + self.assertEqual(cert.serial_number, serial_number) + 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): From 74fdb90226176b6bd5dd56263ad4ae4c90cab542 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Thu, 6 Aug 2026 03:07:51 +0530 Subject: [PATCH 08/21] [fix] Fixes by @coderabbitai --- openwisp_controller/config/api/views.py | 8 ++-- openwisp_controller/connection/api/views.py | 2 + .../connection/tests/test_api.py | 4 +- openwisp_controller/geo/api/views.py | 7 +++- .../geo/estimated_location/tests/tests.py | 2 +- openwisp_controller/geo/tests/test_admin.py | 37 ++++++++++++++++++- openwisp_controller/geo/tests/test_api.py | 17 +++++---- openwisp_controller/pki/tests/test_api.py | 4 +- .../tests/test_users_integration.py | 5 +++ 9 files changed, 66 insertions(+), 20 deletions(-) diff --git a/openwisp_controller/config/api/views.py b/openwisp_controller/config/api/views.py index 5d6858b6a..7ec9a726d 100644 --- a/openwisp_controller/config/api/views.py +++ b/openwisp_controller/config/api/views.py @@ -98,7 +98,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): @@ -154,7 +154,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 +168,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 +190,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/connection/api/views.py b/openwisp_controller/connection/api/views.py index 1cd49a705..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()] @@ -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 ( diff --git a/openwisp_controller/connection/tests/test_api.py b/openwisp_controller/connection/tests/test_api.py index 769a424c8..32f3c328d 100644 --- a/openwisp_controller/connection/tests/test_api.py +++ b/openwisp_controller/connection/tests/test_api.py @@ -555,7 +555,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") @@ -573,7 +573,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") diff --git a/openwisp_controller/geo/api/views.py b/openwisp_controller/geo/api/views.py index cc630ce8a..dcdbecd37 100644 --- a/openwisp_controller/geo/api/views.py +++ b/openwisp_controller/geo/api/views.py @@ -166,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() @@ -294,7 +295,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): @@ -315,6 +318,7 @@ class FloorPlanListCreateView(ProtectedAPIMixin, generics.ListCreateAPIView): pagination_class = OpenWispPagination filter_backends = [filters.DjangoFilterBackend] filterset_class = FloorPlanOrganizationFilter + select_related_organization = False class FloorPlanDetailView( @@ -323,6 +327,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/tests/tests.py b/openwisp_controller/geo/estimated_location/tests/tests.py index b2de2902e..0c1065fb9 100644 --- a/openwisp_controller/geo/estimated_location/tests/tests.py +++ b/openwisp_controller/geo/estimated_location/tests/tests.py @@ -729,7 +729,7 @@ def _verify_location_details(device, mocked_response): device2.save() # 3 queries related to notifications cleanup device2.refresh_from_db() - with self.assertNumQueries(16): + with self.assertNumQueries(15): manage_estimated_locations(device2.pk, device2.last_ip) mock_info.assert_called_once_with( f"Estimated location saved successfully for {device2.pk}" diff --git a/openwisp_controller/geo/tests/test_admin.py b/openwisp_controller/geo/tests/test_admin.py index 9fe50603c..7efe23da4 100644 --- a/openwisp_controller/geo/tests/test_admin.py +++ b/openwisp_controller/geo/tests/test_admin.py @@ -3,7 +3,7 @@ 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 @@ -13,7 +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 +from ..admin import DeviceLocationInline, FloorPlanAdmin from .utils import TestGeoMixin Device = load_model("config", "Device") @@ -203,6 +203,39 @@ def test_location_readonly_fields(self): response = self.client.get(url) self.assertNotContains(response, ' Date: Thu, 6 Aug 2026 03:48:31 +0530 Subject: [PATCH 09/21] [fix] Fixed tests --- openwisp_controller/geo/estimated_location/tests/tests.py | 2 +- openwisp_controller/pki/tests/test_api.py | 2 +- openwisp_controller/tests/test_users_integration.py | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/openwisp_controller/geo/estimated_location/tests/tests.py b/openwisp_controller/geo/estimated_location/tests/tests.py index 0c1065fb9..02f8478ef 100644 --- a/openwisp_controller/geo/estimated_location/tests/tests.py +++ b/openwisp_controller/geo/estimated_location/tests/tests.py @@ -600,7 +600,7 @@ def _verify_location_details(device, mocked_response): with self.subTest("Test Estimated location created when device is created"): device = self._create_device(last_ip="172.217.22.14") - with self.assertNumQueries(15): + with self.assertNumQueries(16): manage_estimated_locations(device.pk, device.last_ip) location = device.devicelocation.location mocked_response.ip_address = device.last_ip diff --git a/openwisp_controller/pki/tests/test_api.py b/openwisp_controller/pki/tests/test_api.py index 44b77d6af..37b8a7323 100644 --- a/openwisp_controller/pki/tests/test_api.py +++ b/openwisp_controller/pki/tests/test_api.py @@ -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") diff --git a/openwisp_controller/tests/test_users_integration.py b/openwisp_controller/tests/test_users_integration.py index 1b4e2fab9..dfaab68fe 100644 --- a/openwisp_controller/tests/test_users_integration.py +++ b/openwisp_controller/tests/test_users_integration.py @@ -14,6 +14,7 @@ class TestUsersIntegration(GetEditFormInlineMixin, TestUsersAdmin): def _get_disabled_org_test_excluded_inline(self): inlines = super()._get_disabled_org_test_excluded_inline() inlines += [OrganizationLimitsInline] + return inlines del TestUsersAdmin From a6e0c463b43bee1761032103d0fd529c3d9b2467 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Mon, 10 Aug 2026 15:24:18 +0530 Subject: [PATCH 10/21] [fix] Fixed failing tests --- openwisp_controller/config/admin.py | 7 +++--- openwisp_controller/config/api/views.py | 10 ++++++++ .../config/tests/test_admin.py | 2 ++ openwisp_controller/config/tests/test_api.py | 5 +++- openwisp_controller/config/tests/test_vpn.py | 6 ++++- .../connection/api/serializers.py | 8 +++++- .../connection/tests/test_api.py | 25 +++++++++++++++---- openwisp_controller/geo/api/views.py | 8 +++--- .../geo/estimated_location/tests/tests.py | 4 +-- openwisp_controller/geo/tests/test_admin.py | 9 ------- openwisp_controller/geo/tests/test_api.py | 10 ++++---- openwisp_controller/mixins.py | 7 +++++- openwisp_controller/pki/admin.py | 13 +++++++--- openwisp_controller/pki/tests/test_admin.py | 6 ++--- .../tests/test_users_integration.py | 4 +++ 15 files changed, 84 insertions(+), 40 deletions(-) diff --git a/openwisp_controller/config/admin.py b/openwisp_controller/config/admin.py index c01028cc4..1dc4f9a71 100644 --- a/openwisp_controller/config/admin.py +++ b/openwisp_controller/config/admin.py @@ -876,9 +876,7 @@ def deactivate_device(self, request, queryset): @admin.action(description=_("Activate selected devices"), permissions=["change"]) def activate_device(self, request, queryset): - disabled_org_devices = list( - queryset.filter(organization__is_active=False).iterator() - ) + 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 @@ -1211,7 +1209,8 @@ def save_clones(view, user, queryset, organization=None): 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, diff --git a/openwisp_controller/config/api/views.py b/openwisp_controller/config/api/views.py index 7ec9a726d..db86279f6 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 ( @@ -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() diff --git a/openwisp_controller/config/tests/test_admin.py b/openwisp_controller/config/tests/test_admin.py index 97e21df4f..91b531fd3 100644 --- a/openwisp_controller/config/tests/test_admin.py +++ b/openwisp_controller/config/tests/test_admin.py @@ -544,6 +544,7 @@ 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): @@ -554,6 +555,7 @@ def test_vpn_cert_fk_queryset(self): hidden=[data["vpn2"].cert.name, data["vpn_inactive"].cert.name], select_widget=True, administrator=True, + superuser_hidden=[data["vpn_inactive"].cert.name], ) def test_changelist_recover_deleted_button(self): diff --git a/openwisp_controller/config/tests/test_api.py b/openwisp_controller/config/tests/test_api.py index 85d9fe333..ddc0e86c5 100644 --- a/openwisp_controller/config/tests/test_api.py +++ b/openwisp_controller/config/tests/test_api.py @@ -630,10 +630,13 @@ def test_device_activate_deactivate_api_disabled_org(self): 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, 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") diff --git a/openwisp_controller/config/tests/test_vpn.py b/openwisp_controller/config/tests/test_vpn.py index ab3ae6942..3cd08f50b 100644 --- a/openwisp_controller/config/tests/test_vpn.py +++ b/openwisp_controller/config/tests/test_vpn.py @@ -819,6 +819,10 @@ def test_trigger_vpn_server_endpoint_invalid_vpn_id(self): @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 @@ -828,7 +832,7 @@ def test_trigger_vpn_server_endpoint_disabled_org(self, mocked_post): auth_token="secret", vpn_id=str(vpn.id), ) - mocked_post.assert_not_called() + mocked_post.assert_called_once() class TestWireguardTransaction(BaseTestVpn, TestWireguardVpnMixin, TransactionTestCase): diff --git a/openwisp_controller/connection/api/serializers.py b/openwisp_controller/connection/api/serializers.py index 8863f95fa..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, FilterSerializerByOrgManaged): +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/tests/test_api.py b/openwisp_controller/connection/tests/test_api.py index 32f3c328d..56b43c65d 100644 --- a/openwisp_controller/connection/tests/test_api.py +++ b/openwisp_controller/connection/tests/test_api.py @@ -195,7 +195,7 @@ def test_command_create_api_disabled_org(self): data=json.dumps(payload), content_type="application/json", ) - self.assertEqual(response.status_code, 201) + self.assertEqual(response.status_code, 403) def test_command_disabled_org_api_crud(self): org = self.device_conn.device.organization @@ -210,8 +210,11 @@ def test_command_disabled_org_api_crud(self): command, detail_url=detail_url, list_url=list_url, - operations=("list", "retrieve"), + 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 @@ -632,7 +635,7 @@ def test_post_deviceconnection_list_disabled_org(self): "failure_reason": "", } response = self.client.post(path, data, content_type="application/json") - self.assertEqual(response.status_code, 400) + self.assertEqual(response.status_code, 403) def test_deviceconnection_disabled_org_api_crud(self): dc = self._create_device_connection() @@ -643,14 +646,26 @@ def test_deviceconnection_disabled_org_api_crud(self): 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", "update", "delete"), + 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): @@ -719,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/geo/api/views.py b/openwisp_controller/geo/api/views.py index dcdbecd37..24cd6fcf3 100644 --- a/openwisp_controller/geo/api/views.py +++ b/openwisp_controller/geo/api/views.py @@ -210,12 +210,10 @@ 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")) - device = self.get_parent_queryset().first() - if device and not device.organization.is_active: - raise PermissionDenied() else: # PATCH requests where the object does not exist should still # return a 404 response. diff --git a/openwisp_controller/geo/estimated_location/tests/tests.py b/openwisp_controller/geo/estimated_location/tests/tests.py index 02f8478ef..b2de2902e 100644 --- a/openwisp_controller/geo/estimated_location/tests/tests.py +++ b/openwisp_controller/geo/estimated_location/tests/tests.py @@ -600,7 +600,7 @@ def _verify_location_details(device, mocked_response): with self.subTest("Test Estimated location created when device is created"): device = self._create_device(last_ip="172.217.22.14") - with self.assertNumQueries(16): + with self.assertNumQueries(15): manage_estimated_locations(device.pk, device.last_ip) location = device.devicelocation.location mocked_response.ip_address = device.last_ip @@ -729,7 +729,7 @@ def _verify_location_details(device, mocked_response): device2.save() # 3 queries related to notifications cleanup device2.refresh_from_db() - with self.assertNumQueries(15): + with self.assertNumQueries(16): manage_estimated_locations(device2.pk, device2.last_ip) mock_info.assert_called_once_with( f"Estimated location saved successfully for {device2.pk}" diff --git a/openwisp_controller/geo/tests/test_admin.py b/openwisp_controller/geo/tests/test_admin.py index 7efe23da4..8c0883b81 100644 --- a/openwisp_controller/geo/tests/test_admin.py +++ b/openwisp_controller/geo/tests/test_admin.py @@ -218,15 +218,6 @@ def test_floorplan_admin_get_form(self): form = floorplan_admin.get_form(request, floorplan) self.assertEqual(form._user, request.user) - def test_floorplan_admin_get_form_read_only_fallback(self): - floorplan_admin, request, floorplan = self._get_floorplan_admin_request() - with mock.patch( - "django_loci.base.admin.AbstractFloorPlanAdmin.get_form", - side_effect=KeyError("location"), - ): - form = floorplan_admin.get_form(request, floorplan) - self.assertEqual(form._user, request.user) - def test_floorplan_admin_get_form_reraises_unrelated_keyerror(self): floorplan_admin, request, floorplan = self._get_floorplan_admin_request() with mock.patch( diff --git a/openwisp_controller/geo/tests/test_api.py b/openwisp_controller/geo/tests/test_api.py index 2c8845223..ea5e360de 100644 --- a/openwisp_controller/geo/tests/test_api.py +++ b/openwisp_controller/geo/tests/test_api.py @@ -954,7 +954,7 @@ def test_create_devicelocation_using_related_ids(self): floorplan = self._create_floorplan() location = floorplan.location url = reverse("geo_api:device_location", args=[device.id]) - with self.assertNumQueries(18): + with self.assertNumQueries(17): response = self.client.put( url, data={ @@ -1011,7 +1011,7 @@ def test_create_devicelocation_location_floorplan(self): "floorplan.image": self._get_simpleuploadedfile(), "indoor": ["12.342,23.541"], } - with self.assertNumQueries(32): + with self.assertNumQueries(31): response = self.client.put( url, encode_multipart(BOUNDARY, data), content_type=MULTIPART_CONTENT ) @@ -1078,7 +1078,7 @@ def test_create_devicelocation_only_location(self): "type": "indoor", } } - with self.assertNumQueries(21): + with self.assertNumQueries(20): response = self.client.put(url, data=data, content_type="application/json") self.assertEqual(response.status_code, 201) self.assertEqual(self.location_model.objects.count(), 1) @@ -1095,7 +1095,7 @@ def test_create_devicelocation_only_floorplan(self): "floorplan.floor": 1, "floorplan.image": self._get_simpleuploadedfile(), } - with self.assertNumQueries(8): + with self.assertNumQueries(7): response = self.client.put( url, encode_multipart(BOUNDARY, data), content_type=MULTIPART_CONTENT ) @@ -1118,7 +1118,7 @@ def test_create_devicelocation_existing_location_new_floorplan(self): "floorplan.image": self._get_simpleuploadedfile(), "indoor": ["12.342,23.541"], } - with self.assertNumQueries(26): + with self.assertNumQueries(25): response = self.client.put( url, encode_multipart(BOUNDARY, data), content_type=MULTIPART_CONTENT ) diff --git a/openwisp_controller/mixins.py b/openwisp_controller/mixins.py index 5908031df..713c4d8da 100644 --- a/openwisp_controller/mixins.py +++ b/openwisp_controller/mixins.py @@ -17,7 +17,12 @@ def _has_permissions(self, request, view, perm, obj=None): device = getattr(obj, self._device_field) else: device = view.get_parent_queryset().first() - return perm and device and not device.is_deactivated() + return ( + perm + and device + and not device.is_deactivated() + and (device.organization.is_active or request.method == "DELETE") + ) def has_permission(self, request, view): perm = super().has_permission(request, view) diff --git a/openwisp_controller/pki/admin.py b/openwisp_controller/pki/admin.py index 58317fb44..b0554376b 100644 --- a/openwisp_controller/pki/admin.py +++ b/openwisp_controller/pki/admin.py @@ -2,6 +2,7 @@ from django.contrib.admin import action from django.db.models import Q from django.utils.translation import gettext_lazy as _ +from django.utils.translation import ngettext_lazy from django_x509.base.admin import AbstractCaAdmin, AbstractCertAdmin from reversion.admin import VersionAdmin from swapper import load_model @@ -18,12 +19,18 @@ def _exclude_disabled_org(self, request, queryset): allowed = queryset.filter( Q(organization__isnull=True) | Q(organization__is_active=True) ) - skipped = queryset.count() - allowed.count() + skipped = queryset.exclude( + Q(organization__isnull=True) | Q(organization__is_active=True) + ).count() if skipped: self.message_user( request, - _("%d item(s) belonging to a disabled organization were skipped.") - % skipped, + ngettext_lazy( + "%(count)d item belonging to a disabled organization was skipped.", + "%(count)d items belonging to a disabled organization were skipped.", + skipped, + ) + % {"count": skipped}, level=messages.WARNING, ) return allowed diff --git a/openwisp_controller/pki/tests/test_admin.py b/openwisp_controller/pki/tests/test_admin.py index e87774adf..4557c3524 100644 --- a/openwisp_controller/pki/tests/test_admin.py +++ b/openwisp_controller/pki/tests/test_admin.py @@ -199,7 +199,7 @@ def test_ca_renew_action_skips_disabled_org(self): # renewal must also leave its certs untouched self.assertEqual(str(cert.serial_number), str(old_cert_serial)) self.assertContains( - response, "1 item(s) belonging to a disabled organization were skipped." + response, "1 item belonging to a disabled organization was skipped." ) def test_cert_actions_skip_disabled_org(self): @@ -215,7 +215,7 @@ def test_cert_actions_skip_disabled_org(self): cert.refresh_from_db() self.assertEqual(cert.revoked, False) self.assertContains( - response, "1 item(s) belonging to a disabled organization were skipped." + response, "1 item belonging to a disabled organization was skipped." ) old_serial = cert.serial_number renew_payload = { @@ -227,5 +227,5 @@ def test_cert_actions_skip_disabled_org(self): cert.refresh_from_db() self.assertEqual(str(cert.serial_number), str(old_serial)) self.assertContains( - response, "1 item(s) belonging to a disabled organization were skipped." + response, "1 item belonging to a disabled organization was skipped." ) diff --git a/openwisp_controller/tests/test_users_integration.py b/openwisp_controller/tests/test_users_integration.py index dfaab68fe..a58931823 100644 --- a/openwisp_controller/tests/test_users_integration.py +++ b/openwisp_controller/tests/test_users_integration.py @@ -13,6 +13,10 @@ class TestUsersIntegration(GetEditFormInlineMixin, TestUsersAdmin): 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 From 5d20c0fd8513887358d0e458aa2a26b1390e5d50 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Mon, 10 Aug 2026 16:32:12 +0530 Subject: [PATCH 11/21] [fix] Fixed failing tests --- openwisp_controller/config/api/views.py | 4 +- openwisp_controller/config/apps.py | 9 ++-- .../config/controller/views.py | 13 +++++- openwisp_controller/config/handlers.py | 30 ++++--------- openwisp_controller/config/tasks.py | 25 +++++------ .../config/tests/test_controller.py | 42 +++++++++++++++++-- .../config/tests/test_handlers.py | 17 +++++++- 7 files changed, 94 insertions(+), 46 deletions(-) diff --git a/openwisp_controller/config/api/views.py b/openwisp_controller/config/api/views.py index db86279f6..0b26d1856 100644 --- a/openwisp_controller/config/api/views.py +++ b/openwisp_controller/config/api/views.py @@ -141,8 +141,8 @@ 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). + # 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): 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/controller/views.py b/openwisp_controller/config/controller/views.py index 36a41b1e4..0f2f6945b 100644 --- a/openwisp_controller/config/controller/views.py +++ b/openwisp_controller/config/controller/views.py @@ -116,7 +116,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"]) @@ -417,6 +422,12 @@ def post(self, request, *args, **kwargs): device = self.model.objects.select_related("config").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 diff --git a/openwisp_controller/config/handlers.py b/openwisp_controller/config/handlers.py index d4b93168a..68450bad2 100644 --- a/openwisp_controller/config/handlers.py +++ b/openwisp_controller/config/handlers.py @@ -11,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") @@ -190,26 +189,11 @@ def devicegroup_templates_change_handler(instance, **kwargs): def organization_disabled_handler(instance, **kwargs): """ - Asynchronously deactivates devices and invalidates controller view caches - when an organization transitions from active to inactive. - - Re-enabling an organization triggers no device reactivation. + 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 - organization_id = str(instance.id) - - def _on_commit(): - chain( - tasks.deactivate_organization_devices.s(organization_id), - tasks.invalidate_controller_views_cache.si(organization_id), - ).delay() - - transaction.on_commit(_on_commit) + 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 fe7f83960..931da7ba3 100644 --- a/openwisp_controller/config/tasks.py +++ b/openwisp_controller/config/tasks.py @@ -126,17 +126,14 @@ def invalidate_devicegroup_cache_delete(instance_id, model_name, **kwargs): def trigger_vpn_server_endpoint(endpoint, auth_token, vpn_id): Vpn = load_model("config", "Vpn") try: - vpn = Vpn.objects.select_related("organization").get(pk=vpn_id) + vpn = Vpn.objects.get(pk=vpn_id) except Vpn.DoesNotExist: logger.error(f"VPN Server UUID: {vpn_id} does not exist.") return - if vpn.organization_id and not vpn.organization.is_active: - logger.info( - "Skipping update webhook for VPN Server UUID: %s of disabled organization", - vpn_id, - ) - 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}" @@ -234,10 +231,14 @@ def deactivate_organization_devices(organization_id): 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") + 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() diff --git a/openwisp_controller/config/tests/test_controller.py b/openwisp_controller/config/tests/test_controller.py index c482f2109..9d895eadb 100644 --- a/openwisp_controller/config/tests/test_controller.py +++ b/openwisp_controller/config/tests/test_controller.py @@ -1233,10 +1233,46 @@ def test_register_reregistration_403_disabled_org(self): self._create_config(device=device) org.is_active = False org.save(update_fields=["is_active"]) - response = self.client.post( - self.register_url, - self._get_reregistration_payload(device, name=TEST_MACADDR_NAME), + 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._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): diff --git a/openwisp_controller/config/tests/test_handlers.py b/openwisp_controller/config/tests/test_handlers.py index c0e385827..f72929f00 100644 --- a/openwisp_controller/config/tests/test_handlers.py +++ b/openwisp_controller/config/tests/test_handlers.py @@ -1,10 +1,13 @@ from unittest.mock import DEFAULT, patch 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(CreateConfigMixin, TransactionTestCase): @patch("openwisp_controller.config.handlers.chain") @@ -14,6 +17,15 @@ def test_organization_disabled_handler(self, mocked_chain): 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_chain.assert_called_once_with( @@ -21,6 +33,9 @@ def test_organization_disabled_handler(self, mocked_chain): 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_chain.reset_mock() with self.subTest("Test task not executed on saving inactive org"): @@ -50,7 +65,7 @@ def test_deactivate_organization_devices(self): device.refresh_from_db() config.refresh_from_db() self.assertEqual(device._is_deactivated, True) - self.assertEqual(config.status in ("deactivating", "deactivated"), True) + self.assertIn(config.status, ("deactivating", "deactivated")) with self.subTest("Re-enabling org does not reactivate devices"): org.is_active = True From 00fb369cbc94d122ef5ee8a3f4f41c0d9bf9d8e3 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Mon, 10 Aug 2026 22:53:45 +0530 Subject: [PATCH 12/21] [fix] Fixed failing tests --- openwisp_controller/config/base/config.py | 4 +++ openwisp_controller/config/base/device.py | 10 ++++--- .../config/base/device_group.py | 6 ++++ openwisp_controller/config/base/vpn.py | 12 ++++++-- .../config/tests/test_config.py | 13 +++++++++ .../config/tests/test_device.py | 29 +++++++++++++++++++ .../config/tests/test_device_group.py | 19 ++++++++++++ openwisp_controller/config/tests/test_vpn.py | 21 ++++++++++++++ openwisp_controller/connection/base/models.py | 8 +++++ .../connection/tests/test_admin.py | 1 + .../connection/tests/test_models.py | 15 ++++++++++ 11 files changed, 132 insertions(+), 6 deletions(-) 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..7d7d0920b 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 @@ -509,10 +510,11 @@ def manage_devices_group_templates(cls, device_ids, old_group_ids, group_id): 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(): + 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..6d0bc83d6 100644 --- a/openwisp_controller/config/base/vpn.py +++ b/openwisp_controller/config/base/vpn.py @@ -257,7 +257,11 @@ def save(self, *args, **kwargs): if not created: self._check_changes() create_dh = False - if self.ca and (not self.cert or self.cert.ca_id != self.ca_id): + if ( + self.ca + and (not self.cert or self.cert.ca_id != self.ca_id) + and (not self.organization_id or self.organization.is_active) + ): self.cert = self._auto_create_cert() if self._is_backend_type("openvpn") and not self.dh: self.dh = self._placeholder_dh @@ -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/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_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_vpn.py b/openwisp_controller/config/tests/test_vpn.py index 3cd08f50b..64ffa88b5 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="") diff --git a/openwisp_controller/connection/base/models.py b/openwisp_controller/connection/base/models.py index 17a06f7bb..d9161149d 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 diff --git a/openwisp_controller/connection/tests/test_admin.py b/openwisp_controller/connection/tests/test_admin.py index 37d558044..8e22a00a1 100644 --- a/openwisp_controller/connection/tests/test_admin.py +++ b/openwisp_controller/connection/tests/test_admin.py @@ -97,6 +97,7 @@ 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): diff --git a/openwisp_controller/connection/tests/test_models.py b/openwisp_controller/connection/tests/test_models.py index e3ed6bb97..9aa7f88ec 100644 --- a/openwisp_controller/connection/tests/test_models.py +++ b/openwisp_controller/connection/tests/test_models.py @@ -265,6 +265,21 @@ def test_connect_deactivated_device(self): dc2.connect() mocked_conn.assert_called_once() + with self.subTest("disabled org, not deactivating: connect blocked"): + cred3 = self._create_credentials(name="cred-disabled-org") + device3 = self._create_device( + name="disabled-org-device", mac_address="11:22:33:44:55:77" + ) + self._create_config(device=device3) + dc3 = self._create_device_connection(credentials=cred3, device=device3) + device3.organization.is_active = False + device3.organization.save(update_fields=["is_active"]) + with mock.patch.object(dc3.connector_instance, "connect") as mocked_conn: + dc3.connect() + mocked_conn.assert_not_called() + self.assertEqual(dc3.is_working, False) + self.assertEqual(dc3.failure_reason, "Organization is disabled") + def test_credentials_schema(self): # unrecognized parameter try: From 7041e27fe3ad11826439553ed0682d503d23ac1a Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Tue, 11 Aug 2026 00:17:26 +0530 Subject: [PATCH 13/21] [fix] Fixed tests --- openwisp_controller/config/base/device.py | 2 +- openwisp_controller/config/tests/test_admin.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/openwisp_controller/config/base/device.py b/openwisp_controller/config/base/device.py index 7d7d0920b..8fd4730e8 100644 --- a/openwisp_controller/config/base/device.py +++ b/openwisp_controller/config/base/device.py @@ -509,7 +509,7 @@ 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) + 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 diff --git a/openwisp_controller/config/tests/test_admin.py b/openwisp_controller/config/tests/test_admin.py index 91b531fd3..091710441 100644 --- a/openwisp_controller/config/tests/test_admin.py +++ b/openwisp_controller/config/tests/test_admin.py @@ -549,13 +549,13 @@ def test_vpn_ca_fk_queryset(self): 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, - superuser_hidden=[data["vpn_inactive"].cert.name], ) def test_changelist_recover_deleted_button(self): From b5d7f462cfbce723e8c0c0e4dc0c5489cdd71d1c Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Tue, 11 Aug 2026 21:04:08 +0530 Subject: [PATCH 14/21] [fix] Fixed sample users test --- tests/openwisp2/sample_users/tests.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 From dface4c813f190fdc694514420cf08483c30795b Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Fri, 14 Aug 2026 10:51:36 +0530 Subject: [PATCH 15/21] [fix] Made requested changes --- openwisp_controller/config/base/vpn.py | 10 +-- .../config/controller/views.py | 3 +- .../config/tests/test_admin.py | 63 +++++++++++++-- .../config/tests/test_controller.py | 35 +++++++++ .../config/tests/test_handlers.py | 10 ++- openwisp_controller/config/tests/test_vpn.py | 78 +++++++++++++++++++ openwisp_controller/connection/base/models.py | 5 +- .../connection/tests/test_models.py | 15 ---- .../connection/tests/test_tasks.py | 21 +++++ openwisp_controller/geo/tests/test_admin.py | 2 + openwisp_controller/pki/admin.py | 1 - openwisp_controller/pki/api/views.py | 4 + openwisp_controller/pki/tests/test_admin.py | 19 +++-- openwisp_controller/pki/tests/test_api.py | 19 +++-- openwisp_controller/subnet_division/tasks.py | 11 ++- .../subnet_division/tests/test_models.py | 32 ++++++++ .../0006_user_password_based_token.py | 31 ++++++++ 17 files changed, 315 insertions(+), 44 deletions(-) create mode 100644 tests/openwisp2/sample_users/migrations/0006_user_password_based_token.py diff --git a/openwisp_controller/config/base/vpn.py b/openwisp_controller/config/base/vpn.py index 6d0bc83d6..52b11b868 100644 --- a/openwisp_controller/config/base/vpn.py +++ b/openwisp_controller/config/base/vpn.py @@ -252,16 +252,14 @@ 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: self._check_changes() create_dh = False - if ( - self.ca - and (not self.cert or self.cert.ca_id != self.ca_id) - and (not self.organization_id or self.organization.is_active) - ): + if self.ca and (not self.cert or self.cert.ca_id != self.ca_id): self.cert = self._auto_create_cert() if self._is_backend_type("openvpn") and not self.dh: self.dh = self._placeholder_dh @@ -962,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() diff --git a/openwisp_controller/config/controller/views.py b/openwisp_controller/config/controller/views.py index 0f2f6945b..6637bc4c5 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) diff --git a/openwisp_controller/config/tests/test_admin.py b/openwisp_controller/config/tests/test_admin.py index 091710441..96d2dbe53 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: @@ -283,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) @@ -1080,7 +1131,7 @@ def test_device_disabled_org_admin_inline_readonly(self): ) org.is_active = False org.save(update_fields=["is_active"]) - model_admin = admin.site._registry[Device] + model_admin = django_admin.site._registry[Device] self._test_disabled_org_admin_inline_readonly( model_admin, device, active_obj=active_device ) @@ -3076,9 +3127,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), @@ -3106,7 +3159,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_controller.py b/openwisp_controller/config/tests/test_controller.py index 9d895eadb..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 @@ -1490,6 +1491,40 @@ def test_checksum_404_disabled_org(self): ) 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( diff --git a/openwisp_controller/config/tests/test_handlers.py b/openwisp_controller/config/tests/test_handlers.py index f72929f00..f807f35f2 100644 --- a/openwisp_controller/config/tests/test_handlers.py +++ b/openwisp_controller/config/tests/test_handlers.py @@ -86,13 +86,19 @@ def test_deactivate_organization_devices_partial_failure(self): 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, - wraps=Device.deactivate, - side_effect=[Exception, DEFAULT], + side_effect=_deactivate_side_effect, ): tasks.deactivate_organization_devices(org.id) mocked_logger.exception.assert_called_once_with( diff --git a/openwisp_controller/config/tests/test_vpn.py b/openwisp_controller/config/tests/test_vpn.py index 64ffa88b5..a603d56b9 100644 --- a/openwisp_controller/config/tests/test_vpn.py +++ b/openwisp_controller/config/tests/test_vpn.py @@ -533,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 @@ -650,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() @@ -1011,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( @@ -1238,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 = [ @@ -1273,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/connection/base/models.py b/openwisp_controller/connection/base/models.py index d9161149d..437824c51 100644 --- a/openwisp_controller/connection/base/models.py +++ b/openwisp_controller/connection/base/models.py @@ -600,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_models.py b/openwisp_controller/connection/tests/test_models.py index 9aa7f88ec..e3ed6bb97 100644 --- a/openwisp_controller/connection/tests/test_models.py +++ b/openwisp_controller/connection/tests/test_models.py @@ -265,21 +265,6 @@ def test_connect_deactivated_device(self): dc2.connect() mocked_conn.assert_called_once() - with self.subTest("disabled org, not deactivating: connect blocked"): - cred3 = self._create_credentials(name="cred-disabled-org") - device3 = self._create_device( - name="disabled-org-device", mac_address="11:22:33:44:55:77" - ) - self._create_config(device=device3) - dc3 = self._create_device_connection(credentials=cred3, device=device3) - device3.organization.is_active = False - device3.organization.save(update_fields=["is_active"]) - with mock.patch.object(dc3.connector_instance, "connect") as mocked_conn: - dc3.connect() - mocked_conn.assert_not_called() - self.assertEqual(dc3.is_working, False) - self.assertEqual(dc3.failure_reason, "Organization is disabled") - def test_credentials_schema(self): # unrecognized parameter try: 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/tests/test_admin.py b/openwisp_controller/geo/tests/test_admin.py index 8c0883b81..edbf2bd0b 100644 --- a/openwisp_controller/geo/tests/test_admin.py +++ b/openwisp_controller/geo/tests/test_admin.py @@ -318,6 +318,7 @@ def test_device_disabled_org_admin_inline_readonly(self): name="active-device", organization=active_org, mac_address="00:11:22:33:44:70", + key="key-active-device", ) self._create_object_location( location=active_location, content_object=active_device @@ -332,6 +333,7 @@ def test_device_disabled_org_admin_inline_readonly(self): name="disabled-device", organization=disabled_org, mac_address="00:11:22:33:44:71", + key="key-disabled-device", ) self._create_object_location( location=disabled_location, content_object=disabled_device diff --git a/openwisp_controller/pki/admin.py b/openwisp_controller/pki/admin.py index b0554376b..61fa12f3d 100644 --- a/openwisp_controller/pki/admin.py +++ b/openwisp_controller/pki/admin.py @@ -64,7 +64,6 @@ def renew_cert(self, request, queryset): @action(description=_("Revoke selected certificates"), permissions=["change"]) def revoke_action(self, request, queryset): - queryset = _exclude_disabled_org(self, request, queryset) return super().revoke_action(request, queryset) diff --git a/openwisp_controller/pki/api/views.py b/openwisp_controller/pki/api/views.py index f244bf0a3..d138b8bd4 100644 --- a/openwisp_controller/pki/api/views.py +++ b/openwisp_controller/pki/api/views.py @@ -79,6 +79,10 @@ class CertRevokeRenewBaseView(ProtectedAPIMixin, GenericAPIView): class CertRevokeView(CertRevokeRenewBaseView): + # Revocation is a cleanup operation and must remain allowed for + # certificates belonging to disabled organizations (issue #1393). + allow_disabled_organization_writes = True + def post(self, request, pk): """ Revokes the Certificate. diff --git a/openwisp_controller/pki/tests/test_admin.py b/openwisp_controller/pki/tests/test_admin.py index 4557c3524..beb5171f8 100644 --- a/openwisp_controller/pki/tests/test_admin.py +++ b/openwisp_controller/pki/tests/test_admin.py @@ -202,7 +202,7 @@ def test_ca_renew_action_skips_disabled_org(self): response, "1 item belonging to a disabled organization was skipped." ) - def test_cert_actions_skip_disabled_org(self): + 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) @@ -211,12 +211,19 @@ def test_cert_actions_skip_disabled_org(self): org.save(update_fields=["is_active"]) changelist = reverse(f"admin:{self.app_label}_cert_changelist") revoke_payload = {"action": "revoke_action", "_selected_action": [cert.pk]} - response = self.client.post(changelist, revoke_payload, follow=True) + self.client.post(changelist, revoke_payload, follow=True) cert.refresh_from_db() - self.assertEqual(cert.revoked, False) - self.assertContains( - response, "1 item belonging to a disabled organization was skipped." - ) + 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", diff --git a/openwisp_controller/pki/tests/test_api.py b/openwisp_controller/pki/tests/test_api.py index 37b8a7323..99d638af7 100644 --- a/openwisp_controller/pki/tests/test_api.py +++ b/openwisp_controller/pki/tests/test_api.py @@ -247,20 +247,27 @@ def test_cert_post_with_extensions_field(self): self.assertEqual(Cert.objects.count(), 1) self.assertEqual(r.data["extensions"], []) - def test_cert_revoke_renew_api_disabled_org(self): + 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) - serial_number = str(cert.serial_number) org.is_active = False org.save(update_fields=["is_active"]) revoke_path = reverse("pki_api:cert_revoke", args=[cert.pk]) - renew_path = reverse("pki_api:cert_renew", args=[cert.pk]) revoke_response = self.client.post(revoke_path) - self.assertEqual(revoke_response.status_code, 403) + self.assertEqual(revoke_response.status_code, 200) cert.refresh_from_db() - self.assertEqual(cert.revoked, False) - self.assertEqual(cert.serial_number, serial_number) + 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() diff --git a/openwisp_controller/subnet_division/tasks.py b/openwisp_controller/subnet_division/tasks.py index b7f510e60..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, diff --git a/openwisp_controller/subnet_division/tests/test_models.py b/openwisp_controller/subnet_division/tests/test_models.py index 1d0be1da4..26717e848 100644 --- a/openwisp_controller/subnet_division/tests/test_models.py +++ b/openwisp_controller/subnet_division/tests/test_models.py @@ -697,6 +697,38 @@ def test_provision_subnet_ip_skips_disabled_org(self): rule.id, ) + 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/tests/openwisp2/sample_users/migrations/0006_user_password_based_token.py b/tests/openwisp2/sample_users/migrations/0006_user_password_based_token.py new file mode 100644 index 000000000..ac6000b94 --- /dev/null +++ b/tests/openwisp2/sample_users/migrations/0006_user_password_based_token.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2.16 on 2026-07-29 19:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("sample_users", "0005_user_expiration_date_user_user_active_expiry_idx"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="password_based_token", + 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." + ), + null=True, + verbose_name="password based token", + ), + ), + ] From b59418ab1a520f88fdef2a849b797c9059bcac7e Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Fri, 14 Aug 2026 15:06:56 +0530 Subject: [PATCH 16/21] [fix] Fixes by @coderabbitai --- openwisp_controller/config/controller/views.py | 4 +++- openwisp_controller/geo/tests/test_api.py | 2 ++ .../subnet_division/tests/test_models.py | 10 ++++++++-- .../migrations/0006_user_password_based_token.py | 5 +++-- 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/openwisp_controller/config/controller/views.py b/openwisp_controller/config/controller/views.py index 6637bc4c5..c5088259a 100644 --- a/openwisp_controller/config/controller/views.py +++ b/openwisp_controller/config/controller/views.py @@ -420,7 +420,9 @@ 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: diff --git a/openwisp_controller/geo/tests/test_api.py b/openwisp_controller/geo/tests/test_api.py index ea5e360de..b50a83adb 100644 --- a/openwisp_controller/geo/tests/test_api.py +++ b/openwisp_controller/geo/tests/test_api.py @@ -575,11 +575,13 @@ def test_post_floorplan_list_disabled_org(self): "image": self._get_simpleuploadedfile(), "location": location.pk, } + floorplan_count = FloorPlan.objects.count() response = self.client.post(path, data, format="multipart") # blocked incidentally: FilterSerializerByOrgManaged excludes the # disabled organization's location from the "location" field # queryset, not by an explicit disabled-org check on this endpoint self.assertEqual(response.status_code, 400) + self.assertEqual(FloorPlan.objects.count(), floorplan_count) def test_get_location_list(self): path = reverse("geo_api:list_location") diff --git a/openwisp_controller/subnet_division/tests/test_models.py b/openwisp_controller/subnet_division/tests/test_models.py index 26717e848..58a1acee2 100644 --- a/openwisp_controller/subnet_division/tests/test_models.py +++ b/openwisp_controller/subnet_division/tests/test_models.py @@ -690,12 +690,18 @@ def test_provision_subnet_ip_skips_disabled_org(self): ) org.is_active = False org.save(update_fields=["is_active"]) - with patch("openwisp_controller.subnet_division.tasks.logger.info") as mocked: + 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.assert_called_once_with( + 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") 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 ac6000b94..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.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,7 +17,7 @@ class Migration(migrations.Migration): field=models.BooleanField( blank=True, default=None, - help_text=( + 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)" @@ -25,7 +26,7 @@ class Migration(migrations.Migration): " this feature was introduced." ), null=True, - verbose_name="password based token", + verbose_name=_("password based token"), ), ), ] From 220733c351b97663f72dded7850bad36bdf5c619 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Tue, 18 Aug 2026 01:39:53 +0530 Subject: [PATCH 17/21] [fix] Fixed admin actions --- openwisp_controller/config/admin.py | 1 + .../config/tests/test_admin.py | 23 +++++++++- openwisp_controller/pki/admin.py | 42 +------------------ openwisp_controller/pki/tests/test_admin.py | 4 +- 4 files changed, 26 insertions(+), 44 deletions(-) diff --git a/openwisp_controller/config/admin.py b/openwisp_controller/config/admin.py index 1dc4f9a71..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 diff --git a/openwisp_controller/config/tests/test_admin.py b/openwisp_controller/config/tests/test_admin.py index 96d2dbe53..95253e8e5 100644 --- a/openwisp_controller/config/tests/test_admin.py +++ b/openwisp_controller/config/tests/test_admin.py @@ -754,7 +754,9 @@ def test_change_group_action_disabled_org(self): } response = self.client.post(path, post_data, follow=True) self.assertEqual(response.status_code, 200) - self.assertContains(response, "Selected organization is disabled.") + self.assertContains( + response, "Actions cannot modify objects of disabled organizations." + ) device.refresh_from_db() self.assertIsNone(device.group) @@ -772,11 +774,28 @@ def test_activate_device_action_disabled_org(self): response = self.client.post(path, data, follow=True) self.assertEqual(response.status_code, 200) self.assertContains( - response, "Cannot activate devices of a disabled organization" + 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") diff --git a/openwisp_controller/pki/admin.py b/openwisp_controller/pki/admin.py index 61fa12f3d..97be17585 100644 --- a/openwisp_controller/pki/admin.py +++ b/openwisp_controller/pki/admin.py @@ -1,8 +1,4 @@ -from django.contrib import admin, messages -from django.contrib.admin import action -from django.db.models import Q -from django.utils.translation import gettext_lazy as _ -from django.utils.translation import ngettext_lazy +from django.contrib import admin from django_x509.base.admin import AbstractCaAdmin, AbstractCertAdmin from reversion.admin import VersionAdmin from swapper import load_model @@ -15,36 +11,10 @@ Cert = load_model("django_x509", "Cert") -def _exclude_disabled_org(self, request, queryset): - allowed = queryset.filter( - Q(organization__isnull=True) | Q(organization__is_active=True) - ) - skipped = queryset.exclude( - Q(organization__isnull=True) | Q(organization__is_active=True) - ).count() - if skipped: - self.message_user( - request, - ngettext_lazy( - "%(count)d item belonging to a disabled organization was skipped.", - "%(count)d items belonging to a disabled organization were skipped.", - skipped, - ) - % {"count": skipped}, - level=messages.WARNING, - ) - return allowed - - @admin.register(Ca) class CaAdmin(MultitenantAdminMixin, AbstractCaAdmin, VersionAdmin): history_latest_first = True - @action(description=_("Renew selected CAs"), permissions=["change"]) - def renew_ca(self, request, queryset): - queryset = _exclude_disabled_org(self, request, queryset) - return super().renew_ca(request, queryset) - CaAdmin.fields.insert(2, "organization") CaAdmin.list_filter.insert(0, MultitenantOrgFilter) @@ -56,15 +26,7 @@ def renew_ca(self, request, queryset): class CertAdmin(MultitenantAdminMixin, AbstractCertAdmin, VersionAdmin): multitenant_shared_relations = ("ca",) history_latest_first = True - - @action(description=_("Renew selected certificates"), permissions=["change"]) - def renew_cert(self, request, queryset): - queryset = _exclude_disabled_org(self, request, queryset) - return super().renew_cert(request, queryset) - - @action(description=_("Revoke selected certificates"), permissions=["change"]) - def revoke_action(self, request, queryset): - return super().revoke_action(request, queryset) + disabled_organization_action_exclusions = ("revoke_action",) CertAdmin.fields.insert(2, "organization") diff --git a/openwisp_controller/pki/tests/test_admin.py b/openwisp_controller/pki/tests/test_admin.py index beb5171f8..403aec067 100644 --- a/openwisp_controller/pki/tests/test_admin.py +++ b/openwisp_controller/pki/tests/test_admin.py @@ -199,7 +199,7 @@ def test_ca_renew_action_skips_disabled_org(self): # renewal must also leave its certs untouched self.assertEqual(str(cert.serial_number), str(old_cert_serial)) self.assertContains( - response, "1 item belonging to a disabled organization was skipped." + response, "Actions cannot modify objects of disabled organizations." ) def test_cert_revoke_action_allowed_for_disabled_org(self): @@ -234,5 +234,5 @@ def test_cert_renew_action_skips_disabled_org(self): cert.refresh_from_db() self.assertEqual(str(cert.serial_number), str(old_serial)) self.assertContains( - response, "1 item belonging to a disabled organization was skipped." + response, "Actions cannot modify objects of disabled organizations." ) From 3adef55ffa405b9d57c2bb3e0250ffb6f50e3e78 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Tue, 18 Aug 2026 01:59:11 +0530 Subject: [PATCH 18/21] [docs] Added anchor tag to deactivated config device status --- docs/user/device-config-status.rst | 2 ++ 1 file changed, 2 insertions(+) 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`` --------------- From 9705981b50f30c3ad865a797535afc27a0a063e1 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Thu, 20 Aug 2026 02:15:46 +0530 Subject: [PATCH 19/21] [change] Updated test to make them complete --- .github/workflows/ci.yml | 1 + .../config/tests/test_admin.py | 47 +++++++++++++++++-- .../connection/tests/test_admin.py | 9 +++- openwisp_controller/geo/tests/test_admin.py | 13 ++++- openwisp_controller/pki/tests/test_admin.py | 17 ++++++- 5 files changed, 78 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99c14f61f..52bfa660b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,6 +73,7 @@ jobs: 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/openwisp_controller/config/tests/test_admin.py b/openwisp_controller/config/tests/test_admin.py index 95253e8e5..6b8a3677d 100644 --- a/openwisp_controller/config/tests/test_admin.py +++ b/openwisp_controller/config/tests/test_admin.py @@ -1101,7 +1101,19 @@ def test_device_disabled_org_admin_crud(self): self._test_disabled_org_admin_crud( device, change_data={"name": "renamed-device"}, - operations=("view", "change"), + 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 @@ -1123,7 +1135,15 @@ def test_devicegroup_disabled_org_admin_crud(self): org.is_active = False org.save(update_fields=["is_active"]) self._test_disabled_org_admin_crud( - device_group, change_data={"name": "renamed-group"} + 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): @@ -1132,7 +1152,14 @@ def test_template_disabled_org_admin_crud(self): org.is_active = False org.save(update_fields=["is_active"]) self._test_disabled_org_admin_crud( - template, change_data={"name": "renamed-template"} + 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): @@ -1140,7 +1167,19 @@ def test_vpn_disabled_org_admin_crud(self): 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"}) + 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") diff --git a/openwisp_controller/connection/tests/test_admin.py b/openwisp_controller/connection/tests/test_admin.py index 8e22a00a1..34845f41a 100644 --- a/openwisp_controller/connection/tests/test_admin.py +++ b/openwisp_controller/connection/tests/test_admin.py @@ -108,7 +108,14 @@ def test_credentials_disabled_org_admin_crud(self): org.is_active = False org.save(update_fields=["is_active"]) self._test_disabled_org_admin_crud( - credentials, change_data={"name": "renamed-credentials"} + 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): diff --git a/openwisp_controller/geo/tests/test_admin.py b/openwisp_controller/geo/tests/test_admin.py index edbf2bd0b..61b8af897 100644 --- a/openwisp_controller/geo/tests/test_admin.py +++ b/openwisp_controller/geo/tests/test_admin.py @@ -139,7 +139,13 @@ def test_location_disabled_org_admin_crud(self): org.is_active = False org.save() self._test_disabled_org_admin_crud( - location, change_data={"name": "renamed-location"} + 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): @@ -151,7 +157,10 @@ def test_floorplan_disabled_org_admin_crud(self): org.is_active = False org.save() self._test_disabled_org_admin_crud( - floorplan, change_data={"floor": 2}, unchanged_field="floor" + 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): diff --git a/openwisp_controller/pki/tests/test_admin.py b/openwisp_controller/pki/tests/test_admin.py index 403aec067..98f91ca4d 100644 --- a/openwisp_controller/pki/tests/test_admin.py +++ b/openwisp_controller/pki/tests/test_admin.py @@ -107,6 +107,7 @@ def test_cert_ca_fk_autocomplete_view(self): visible=[data["ca1"].name], hidden=[data["ca2"].name, data["ca_inactive"].name], administrator=True, + superuser_hidden=[data["ca_inactive"].name], ) def test_cert_changeform_200(self): @@ -150,7 +151,11 @@ def test_ca_disabled_org_admin_crud(self): 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"}) + 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() @@ -166,7 +171,15 @@ def test_cert_disabled_org_admin_crud(self): 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"}) + 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() From 5a1e7fed02e89c4dcb1f7c15a16981e4344b5b93 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Fri, 21 Aug 2026 17:18:02 +0530 Subject: [PATCH 20/21] [fix] Fixed tests --- openwisp_controller/config/tests/test_admin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openwisp_controller/config/tests/test_admin.py b/openwisp_controller/config/tests/test_admin.py index 6b8a3677d..784e24106 100644 --- a/openwisp_controller/config/tests/test_admin.py +++ b/openwisp_controller/config/tests/test_admin.py @@ -562,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): From be92eece33da92dba4aef57f084995806521f565 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Sat, 22 Aug 2026 00:32:43 +0530 Subject: [PATCH 21/21] [docs] Added rule for disabled organization in AGENTS.md --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) 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