diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 062131170..52bfa660b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -72,6 +72,8 @@ jobs:
pip install -U pip wheel setuptools
pip install -U -r requirements-test.txt
pip install -U -e .
+ pip install --upgrade --force-reinstall --no-deps --no-cache-dir https://github.com/openwisp/openwisp-users/tarball/issues/522-disabled-org
+ pip install --upgrade --force-reinstall --no-deps --no-cache-dir https://github.com/openwisp/openwisp-ipam/tarball/fix-disabled-org-handling
pip install ${{ matrix.django-version }}
- name: Start redis
diff --git a/AGENTS.md b/AGENTS.md
index bae0ad9ee..9f626bd25 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -78,6 +78,8 @@ If instructions conflict (please let us know!), repository config and CI workflo
- Watch for cross-organization data leaks, command execution issues, unsafe file paths, unsafe redirects, insecure credentials, and secrets.
- Preserve validation around templates, VPN/PKI material, SSH credentials, device commands, uploaded files, URLs, and subnet/IP data.
+- Objects belonging to a disabled organization must be readable and deletable; creation and updates must be blocked across all relevant write paths. This applies to objects with either a direct or chained/nested relationship to the organization. No other operations should be permitted, except for ordinary cleanup operations.
+- Operations on deactivated devices must be blocked, except for read-only access and cleanup operations required to maintain consistency. Creation, updates, provisioning, configuration, and other mutating operations must not be performed for deactivated devices.
## Troubleshooting
diff --git a/docs/user/device-config-status.rst b/docs/user/device-config-status.rst
index a07fcbaca..82905c792 100644
--- a/docs/user/device-config-status.rst
+++ b/docs/user/device-config-status.rst
@@ -30,6 +30,8 @@ the device to revert to its previous working configuration.
The device is in the process of being deactivated. The configuration is
scheduled to be removed from the device.
+.. _controller_deactivated_config_status:
+
``deactivated``
---------------
diff --git a/openwisp_controller/config/admin.py b/openwisp_controller/config/admin.py
index b82f65ece..ce2b73976 100644
--- a/openwisp_controller/config/admin.py
+++ b/openwisp_controller/config/admin.py
@@ -571,6 +571,7 @@ class DeviceAdmin(MultitenantAdminMixin, BaseConfigAdmin, CopyableFieldsAdmin):
"activate_device",
"delete_selected",
]
+ disabled_organization_action_exclusions = ("deactivate_device",)
org_position = 1 if not app_settings.HARDWARE_ID_ENABLED else 2
list_display.insert(org_position, "organization")
_state_adding = False
@@ -601,7 +602,7 @@ def media(self):
return super().media + forms.Media(js=js, css={"all": css})
def has_change_permission(self, request, obj=None):
- perm = super().has_change_permission(request)
+ perm = super().has_change_permission(request, obj)
if not obj or getattr(request, "_recover_view", False):
return perm
return perm and not obj.is_deactivated()
@@ -726,8 +727,9 @@ def change_group(self, request, queryset):
# Validate all selected devices belong to the same organization
# which is managed by the user.
org_id = None
- if queryset:
- org_id = queryset[0].organization_id
+ first_device = queryset.select_related("organization").first()
+ if first_device:
+ org_id = first_device.organization_id
if not request.user.is_superuser and not request.user.is_manager(org_id):
logger.warning(f'{request.user} does not manage "{org_id}" organization.')
return HttpResponseForbidden()
@@ -738,6 +740,13 @@ def change_group(self, request, queryset):
messages.ERROR,
)
return HttpResponseRedirect(request.get_full_path())
+ if first_device and not first_device.organization.is_active:
+ self.message_user(
+ request,
+ _("Selected organization is disabled."),
+ messages.ERROR,
+ )
+ return HttpResponseRedirect(request.get_full_path())
if "apply" in request.POST:
form = ChangeDeviceGroupForm(data=request.POST, org_id=org_id)
@@ -868,7 +877,24 @@ def deactivate_device(self, request, queryset):
@admin.action(description=_("Activate selected devices"), permissions=["change"])
def activate_device(self, request, queryset):
- self._change_device_status(request, queryset, "activate")
+ disabled_org_devices = list(queryset.filter(organization__is_active=False))
+ if disabled_org_devices:
+ devices_html = ", ".join(
+ self._get_device_path(device) for device in disabled_org_devices
+ )
+ self.message_user(
+ request,
+ mark_safe(
+ _("Cannot activate devices of a disabled organization: %(devices)s")
+ % {"devices": devices_html}
+ ),
+ messages.ERROR,
+ )
+ self._change_device_status(
+ request,
+ queryset.filter(organization__is_active=True),
+ "activate",
+ )
@admin.action(description=delete_selected.short_description, permissions=["delete"])
def delete_selected(self, request, queryset):
@@ -976,15 +1002,18 @@ def get_urls(self):
def get_extra_context(self, pk=None):
ctx = super().get_extra_context(pk)
if pk:
- device = self.model.objects.select_related("config").get(id=pk)
+ device = self.model.objects.select_related("config", "organization").get(
+ id=pk
+ )
ctx.update(
{
"show_deactivate": not device.is_deactivated(),
- "show_activate": device.is_deactivated(),
+ "show_activate": device.is_deactivated()
+ and device.organization.is_active,
"action_checkbox_name": helpers.ACTION_CHECKBOX_NAME,
}
)
- if device.is_deactivated():
+ if ctx["show_activate"]:
ctx["additional_buttons"].append(
{
"raw_html": mark_safe(
@@ -993,7 +1022,7 @@ def get_extra_context(self, pk=None):
)
}
)
- else:
+ elif not device.is_deactivated():
ctx["additional_buttons"].append(
{
"raw_html": mark_safe(
@@ -1178,10 +1207,19 @@ def save_clones(view, user, queryset, organization=None):
# validate organization
if organization:
try:
- validated_org = Organization.objects.get(pk=organization)
+ validated_org = Organization.active.get(pk=organization)
except (ValidationError, Organization.DoesNotExist) as e:
logger.warning(
- f"Detected tampering in clone template form by user {user}: {e}"
+ "Cannot clone template: the organization selected by "
+ f"user {user} does not exist or is disabled: {e}"
+ )
+ view.message_user(
+ request,
+ _(
+ "Cannot clone templates: the selected organization"
+ " does not exist or is disabled."
+ ),
+ messages.ERROR,
)
return
if not user.is_superuser and not user.is_manager(organization):
@@ -1236,11 +1274,11 @@ def save_clones(view, user, queryset, organization=None):
)
if user.is_superuser:
- all_orgs = Organization.objects.all()
+ all_orgs = Organization.active.all()
if all_orgs.count() > 1:
selectable_orgs = all_orgs
elif len(user.organizations_managed) > 1:
- selectable_orgs = Organization.objects.filter(
+ selectable_orgs = Organization.active.filter(
pk__in=user.organizations_managed
)
if selectable_orgs:
diff --git a/openwisp_controller/config/api/serializers.py b/openwisp_controller/config/api/serializers.py
index d74ca28b9..5108f22a3 100644
--- a/openwisp_controller/config/api/serializers.py
+++ b/openwisp_controller/config/api/serializers.py
@@ -112,10 +112,15 @@ class FilterTemplatesByOrganization(serializers.PrimaryKeyRelatedField):
def get_queryset(self):
user = self.context["request"].user
if user.is_superuser:
- queryset = Template.objects.all()
+ queryset = Template.objects.filter(
+ Q(organization__is_active=True) | Q(organization__isnull=True)
+ )
else:
queryset = Template.objects.filter(
- Q(organization__in=user.organizations_managed)
+ Q(
+ organization__in=user.organizations_managed,
+ organization__is_active=True,
+ )
| Q(organization__isnull=True)
)
return queryset
diff --git a/openwisp_controller/config/api/views.py b/openwisp_controller/config/api/views.py
index 5d6858b6a..0b26d1856 100644
--- a/openwisp_controller/config/api/views.py
+++ b/openwisp_controller/config/api/views.py
@@ -3,6 +3,7 @@
from django.db.models import F, Q
from django.http import Http404
from django.urls.base import reverse
+from django.utils.translation import gettext_lazy as _
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import serializers, status
from rest_framework.generics import (
@@ -98,7 +99,7 @@ class DeviceDetailView(ProtectedAPIMixin, RetrieveUpdateDestroyAPIView):
"""
serializer_class = DeviceDetailSerializer
- queryset = Device.objects.select_related("config", "group", "organization")
+ queryset = Device.objects.select_related("config", "group")
permission_classes = ProtectedAPIMixin.permission_classes + (DevicePermission,)
def perform_destroy(self, instance):
@@ -124,6 +125,11 @@ class DeviceActivateView(ProtectedAPIMixin, GenericAPIView):
def post(self, request, *args, **kwargs):
device = self.get_object()
+ if not device.organization.is_active:
+ return Response(
+ {"detail": _("Cannot activate a device of a disabled organization.")},
+ status=status.HTTP_403_FORBIDDEN,
+ )
device.activate()
serializer = DeviceDetailSerializer(
device, context=self.get_serializer_context()
@@ -134,6 +140,10 @@ def post(self, request, *args, **kwargs):
class DeviceDeactivateView(ProtectedAPIMixin, GenericAPIView):
serializer_class = serializers.Serializer
queryset = Device.objects.filter(_is_deactivated=False)
+ # Deactivation stays allowed even when the organization is disabled:
+ # it's the remediation an operator needs if the org-wide deactivation
+ # task failed for this device (see deactivate_organization_devices).
+ allow_disabled_organization_writes = True
def post(self, request, *args, **kwargs):
device = self.get_object()
@@ -154,7 +164,7 @@ class DeviceGroupListCreateView(ProtectedAPIMixin, ListCreateAPIView):
class DeviceGroupDetailView(ProtectedAPIMixin, RetrieveUpdateDestroyAPIView):
serializer_class = DeviceGroupSerializer
- queryset = DeviceGroup.objects.select_related("organization").order_by("-created")
+ queryset = DeviceGroup.objects.order_by("-created")
def get_cached_devicegroup_args_rewrite(cls, org_slugs, common_name):
@@ -168,7 +178,7 @@ def get_cached_devicegroup_args_rewrite(cls, org_slugs, common_name):
class DeviceGroupCommonName(ProtectedAPIMixin, RetrieveAPIView):
serializer_class = DeviceGroupSerializer
- queryset = DeviceGroup.objects.select_related("organization").order_by("-created")
+ queryset = DeviceGroup.objects.order_by("-created")
# Not setting lookup_field makes DRF raise error. but it is not used
lookup_field = "pk"
@@ -190,7 +200,7 @@ def get_device_group(cls, org_slugs, common_name):
)
vpnclient = VpnClient.objects.only("config_id").get(cert_id=cert.id)
group = (
- Device.objects.select_related("group")
+ Device.objects.select_related("group", "group__organization")
.only("group")
.get(config=vpnclient.config_id)
.group
diff --git a/openwisp_controller/config/apps.py b/openwisp_controller/config/apps.py
index 032321db9..fc9083b2e 100644
--- a/openwisp_controller/config/apps.py
+++ b/openwisp_controller/config/apps.py
@@ -11,6 +11,7 @@
)
from swapper import get_model_name, load_model
+from openwisp_users.signals import organization_disabled
from openwisp_utils.admin_theme import register_dashboard_chart
from openwisp_utils.admin_theme.menu import register_menu_group
@@ -105,11 +106,11 @@ def connect_cache_dependencies(self):
DeviceChecksumView.invalidate_get_device_cache_on_config_deactivated
),
),
- # When an organization is disabled, all its devices are deactivated,
- # so we need to invalidate the controller view caches for all objects.
CacheDependency(
- source=self.org_model,
- signal="pre_save",
+ signal_obj=organization_disabled,
+ name="organization_disabled",
+ # organization_disabled signal is fired when the transaction
+ # is committed to the database.
on_commit=False,
target=organization_disabled_handler,
),
diff --git a/openwisp_controller/config/base/config.py b/openwisp_controller/config/base/config.py
index 10c2260aa..82c3e5e25 100644
--- a/openwisp_controller/config/base/config.py
+++ b/openwisp_controller/config/base/config.py
@@ -486,6 +486,10 @@ def manage_vpn_clients(cls, action, instance, pk_set, **kwargs):
).delete()
if action == "post_add":
+ if not instance.device.organization.is_active:
+ # Do not create VPN clients (and their certs) for a
+ # disabled organization
+ return
# A single template change can trigger multiple m2m_changed events.
# Use the full current template set instead of this event's pk_set so
# every attached VPN template has its VpnClient.
diff --git a/openwisp_controller/config/base/device.py b/openwisp_controller/config/base/device.py
index 626d5bb51..8fd4730e8 100644
--- a/openwisp_controller/config/base/device.py
+++ b/openwisp_controller/config/base/device.py
@@ -485,9 +485,10 @@ def create_default_config(self, **options):
creates a new config instance to apply group templates
if group has templates.
"""
- if self.is_deactivated():
- # All modification operations are blocked on deactivated devices.
- # Hence, default config should not be created for deactivated devices.
+ if self.is_deactivated() or not self.organization.is_active:
+ # All modification operations are blocked on deactivated devices
+ # and on devices belonging to a disabled organization. Hence,
+ # default config should not be created in either case.
return
if not (self.group and self.group.templates.exists()):
return
@@ -508,11 +509,12 @@ def manage_devices_group_templates(cls, device_ids, old_group_ids, group_id):
device_ids = [device_ids]
old_group_ids = [old_group_ids]
for device_id, old_group_id in zip(device_ids, old_group_ids):
- device = Device.objects.get(pk=device_id)
- if device.is_deactivated():
+ device = Device.objects.select_related("organization").get(pk=device_id)
+ if device.is_deactivated() or not device.organization.is_active:
# Skip deactivated devices: their configuration is intentionally
# emptied during deactivation, so re-applying group templates
# would break that state and trigger a push to the device.
+ # Also skip devices of a disabled organization.
continue
if not hasattr(device, "config"):
device.create_default_config()
diff --git a/openwisp_controller/config/base/device_group.py b/openwisp_controller/config/base/device_group.py
index 5500e265a..111dfc880 100644
--- a/openwisp_controller/config/base/device_group.py
+++ b/openwisp_controller/config/base/device_group.py
@@ -94,6 +94,12 @@ def manage_group_templates(cls, group_id, old_template_ids, template_ids):
DeviceGroup = load_model("config", "DeviceGroup")
Template = load_model("config", "Template")
device_group = DeviceGroup.objects.get(id=group_id)
+ if not device_group.organization.is_active:
+ # Do not push template changes to a disabled organization's
+ # devices; closes the race window between an organization
+ # being disabled and the async deactivate_organization_devices
+ # task deactivating each of its devices.
+ return
templates = Template.objects.filter(pk__in=template_ids)
old_templates = Template.objects.filter(pk__in=old_template_ids)
for device in device_group.device_set.exclude(_is_deactivated=True).iterator():
diff --git a/openwisp_controller/config/base/vpn.py b/openwisp_controller/config/base/vpn.py
index 61de40a66..52b11b868 100644
--- a/openwisp_controller/config/base/vpn.py
+++ b/openwisp_controller/config/base/vpn.py
@@ -252,6 +252,8 @@ def save(self, *args, **kwargs):
"""
Calls _auto_create_cert() if cert is not set.
"""
+ if self.organization_id and not self.organization.is_active:
+ return super().save(*args, **kwargs)
config = {}
created = self._state.adding
if not created:
@@ -958,6 +960,8 @@ def _get_unique_checks(self, exclude=None, include_meta_constraints=False):
def save(self, *args, **kwargs):
"""Performs automatic provisioning if ``auto_cert`` is True."""
+ if not self.config.device.organization.is_active:
+ return super().save(*args, **kwargs)
if self.auto_cert:
self._auto_x509()
self._auto_ip()
@@ -970,7 +974,11 @@ def _auto_x509(self):
"""
Automatically creates an x509 certificate.
"""
- if not self.vpn._is_backend_type("openvpn") or self.cert:
+ if (
+ not self.vpn._is_backend_type("openvpn")
+ or self.cert
+ or not self.config.device.organization.is_active
+ ):
return
cn = self._get_common_name()
self._auto_create_cert(name=self.config.device.name, common_name=cn)
diff --git a/openwisp_controller/config/controller/views.py b/openwisp_controller/config/controller/views.py
index 2a852e3d2..c5088259a 100644
--- a/openwisp_controller/config/controller/views.py
+++ b/openwisp_controller/config/controller/views.py
@@ -44,7 +44,7 @@ class GetDeviceView(SingleObjectMixin, View):
model = Device
def get_object(self, *args, **kwargs):
- kwargs.update({"organization__is_active": True, "config__isnull": False})
+ kwargs.update({"config__isnull": False})
defer = (
"notes",
"organization__name",
@@ -57,6 +57,7 @@ def get_object(self, *args, **kwargs):
queryset = (
self.model.objects.select_related("organization", "config")
.defer(*defer)
+ .filter(Q(organization__is_active=True) | Q(config__status="deactivating"))
.exclude(config__status="deactivated")
)
return get_object_or_404(queryset, *args, **kwargs)
@@ -116,7 +117,12 @@ def _remove_duplicated_last_ip(self, device):
# dupe.save() triggers signal handlers that call is_deactivated()
# and WHOIS checks that read device.organization.
for dupe in queryset.select_related("organization").only(
- "pk", "key", "last_ip", "_is_deactivated", "organization__id"
+ "pk",
+ "key",
+ "last_ip",
+ "_is_deactivated",
+ "organization__id",
+ "organization__is_active",
):
dupe.last_ip = ""
dupe.save(update_fields=["last_ip"])
@@ -414,9 +420,19 @@ def post(self, request, *args, **kwargs):
# (key is not None only if CONSISTENT_REGISTRATION is enabled)
new = False
try:
- device = self.model.objects.select_related("config").get(key=key)
+ device = self.model.objects.select_related("config", "organization").get(
+ key=key
+ )
if device.is_deactivated():
return ControllerResponse("error: device deactivated", status=403)
+ if device.organization_id != self.organization.id:
+ # The shared secret matched a different (active) organization
+ # than the one this device actually belongs to; treat it the
+ # same as an unrecognized secret rather than leaking that the
+ # device exists.
+ return ControllerResponse("error: unrecognized secret", status=403)
+ if not device.organization.is_active:
+ return ControllerResponse("error: organization disabled", status=403)
# update device info
for attr in self.UPDATABLE_FIELDS:
if attr in request.POST:
diff --git a/openwisp_controller/config/exportable.py b/openwisp_controller/config/exportable.py
index 37fa46ccc..df7d3c4c9 100644
--- a/openwisp_controller/config/exportable.py
+++ b/openwisp_controller/config/exportable.py
@@ -1,7 +1,8 @@
import json
import uuid
-from django.core.exceptions import ObjectDoesNotExist
+from django.core.exceptions import ObjectDoesNotExist, ValidationError
+from django.utils.translation import gettext_lazy as _
from import_export import resources, widgets
from import_export.fields import Field
from swapper import load_model
@@ -11,6 +12,7 @@
Device = load_model("config", "Device")
Config = load_model("config", "Config")
Template = load_model("config", "Template")
+Organization = load_model("openwisp_users", "Organization")
class ManyToManyWidget(widgets.ManyToManyWidget):
@@ -120,6 +122,16 @@ def validate_instance(
super().validate_instance(
instance, import_validation_errors=None, validate_unique=True
)
+ if (
+ instance.organization_id
+ and not Organization.objects.filter(
+ pk=instance.organization_id,
+ is_active=True,
+ ).exists()
+ ):
+ raise ValidationError(
+ {"organization_id": _("Cannot import rows for disabled organizations.")}
+ )
if not instance._has_config():
return
config = instance.config
diff --git a/openwisp_controller/config/handlers.py b/openwisp_controller/config/handlers.py
index 1443d0016..68450bad2 100644
--- a/openwisp_controller/config/handlers.py
+++ b/openwisp_controller/config/handlers.py
@@ -1,3 +1,4 @@
+from celery import chain
from django.db import transaction
from django.dispatch import receiver
from django.utils.translation import gettext_lazy as _
@@ -10,7 +11,6 @@
Config = load_model("config", "Config")
Device = load_model("config", "Device")
DeviceGroup = load_model("config", "DeviceGroup")
-Organization = load_model("openwisp_users", "Organization")
Cert = load_model("django_x509", "Cert")
@@ -189,15 +189,11 @@ def devicegroup_templates_change_handler(instance, **kwargs):
def organization_disabled_handler(instance, **kwargs):
"""
- Asynchronously invalidates device and VPN controller views cache
+ Deactivates devices and invalidates controller view caches when
+ openwisp-users signals that an organization has been disabled.
"""
- if instance.is_active:
- return
- try:
- db_instance = Organization.objects.only("is_active").get(id=instance.id)
- except Organization.DoesNotExist:
- return
- if instance.is_active == db_instance.is_active:
- # No change in is_active
- return
- tasks.invalidate_controller_views_cache.delay(str(instance.id))
+ organization_id = str(instance.pk)
+ chain(
+ tasks.deactivate_organization_devices.s(organization_id),
+ tasks.invalidate_controller_views_cache.si(organization_id),
+ ).delay()
diff --git a/openwisp_controller/config/tasks.py b/openwisp_controller/config/tasks.py
index abf51b310..931da7ba3 100644
--- a/openwisp_controller/config/tasks.py
+++ b/openwisp_controller/config/tasks.py
@@ -130,7 +130,10 @@ def trigger_vpn_server_endpoint(endpoint, auth_token, vpn_id):
except Vpn.DoesNotExist:
logger.error(f"VPN Server UUID: {vpn_id} does not exist.")
return
-
+ # Do not skip disabled organizations: the update is still needed to push
+ # peer removals caused by cascading device deactivation. Peer additions are
+ # already blocked upstream because disabled organizations cannot create new
+ # devices or VPN clients.
# Cache the configuration here makes downloading the configuration faster.
vpn.get_cached_configuration()
task_key = f"vpn_update_task:{vpn_id}"
@@ -218,3 +221,30 @@ def invalidate_controller_views_cache(organization_id):
Vpn.objects.filter(organization_id=organization_id).only("id").iterator()
):
GetVpnView.invalidate_get_vpn_cache(vpn)
+
+
+@shared_task(base=OpenwispCeleryTask)
+def deactivate_organization_devices(organization_id):
+ """
+ Deactivate all active devices of an organization.
+
+ Re-enabling an organization does not reactivate its devices.
+ """
+ Device = load_model("config", "Device")
+ devices = (
+ Device.objects.filter(
+ organization_id=organization_id,
+ _is_deactivated=False,
+ )
+ .select_related("config")
+ .order_by("created")
+ )
+ for device in devices.iterator():
+ try:
+ device.deactivate()
+ except Exception:
+ logger.exception(
+ "Failed to deactivate device %s while disabling organization %s",
+ device.pk,
+ organization_id,
+ )
diff --git a/openwisp_controller/config/tests/test_admin.py b/openwisp_controller/config/tests/test_admin.py
index d6c24e299..784e24106 100644
--- a/openwisp_controller/config/tests/test_admin.py
+++ b/openwisp_controller/config/tests/test_admin.py
@@ -7,7 +7,7 @@
from uuid import uuid4
import django
-from django.contrib import admin
+from django.contrib import admin as django_admin
from django.contrib.admin.models import LogEntry
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
@@ -56,6 +56,7 @@
Location = load_model("geo", "Location")
DeviceLocation = load_model("geo", "DeviceLocation")
Group = load_model("openwisp_users", "Group")
+Organization = load_model("openwisp_users", "Organization")
class TestImportExportMixin:
@@ -119,6 +120,25 @@ def test_device_import(self):
self.assertNotContains(response, "Errors")
self.assertContains(response, "Confirm import")
+ def test_device_import_disabled_organization_rejected(self):
+ org = self._get_org()
+ org.is_active = False
+ org.save(update_fields=["is_active"])
+ contents = (
+ "organization_id,name,mac_address\n"
+ f"{org.pk},TestImportDisabled,00:11:22:09:44:55"
+ )
+ csv = ContentFile(contents)
+ response = self.client.post(
+ reverse(f"admin:{self.app_label}_device_import"),
+ {"format": "0", "import_file": csv},
+ )
+ self.assertNotIn("confirm_form", response.context)
+ self.assertContains(response, "Cannot import rows for disabled organizations.")
+ self.assertEqual(
+ Device.objects.filter(name="TestImportDisabled").exists(), False
+ )
+
def test_device_import_empty_config(self):
org = self._get_org(org_name="default")
contents = (
@@ -264,6 +284,56 @@ def tearDownClass(cls):
super().tearDownClass()
devnull.close()
+ def test_disabled_organization_inlines_write_protected(self):
+ # When an organization is disabled, the inlines that openwisp-controller
+ # attaches to the organization admin (configuration settings, shared
+ # object limits, and geographic settings) must become read-only. The
+ # guard is inherited from openwisp-users' OrganizationAdmin, so this
+ # verifies the downstream inlines are covered without re-implementing
+ # it. The guard only denies add and change; it leaves each inline's own
+ # delete decision alone, so inlines that normally allow deletion keep
+ # allowing it (deletion of a disabled organization's data stays
+ # possible).
+ request = RequestFactory().get("/")
+ request.user = self._get_admin()
+ org_admin = django_admin.site._registry[Organization]
+ disabled_org = self._create_org(name="disabled-inline-org", is_active=False)
+ inlines = org_admin.get_inline_instances(request, disabled_org)
+ inlines_by_name = {type(inline).__name__: inline for inline in inlines}
+ for expected in (
+ "ConfigSettingsInline",
+ "OrganizationLimitsInline",
+ "GeoSettingsInline",
+ ):
+ self.assertIn(expected, inlines_by_name)
+ for name, inline in inlines_by_name.items():
+ with self.subTest(inline=name):
+ self.assertEqual(
+ inline.has_add_permission(request, disabled_org), False
+ )
+ self.assertEqual(
+ inline.has_change_permission(request, disabled_org), False
+ )
+ # inlines that normally permit deletion still do, proving the guard did
+ # not block deleting the disabled organization's related objects
+ for name in ("ConfigSettingsInline", "GeoSettingsInline"):
+ with self.subTest(inline=name):
+ self.assertEqual(
+ inlines_by_name[name].has_delete_permission(request, disabled_org),
+ True,
+ )
+
+ def test_active_organization_inlines_writable(self):
+ request = RequestFactory().get("/")
+ request.user = self._get_admin()
+ org_admin = django_admin.site._registry[Organization]
+ active_org = self._create_org(name="active-inline-org")
+ for inline in org_admin.get_inline_instances(request, active_org):
+ with self.subTest(inline=type(inline).__name__):
+ self.assertEqual(
+ inline.has_change_permission(request, active_org), True
+ )
+
def test_device_and_template_different_organization(self):
org1 = self._get_org()
template = self._create_template(organization=org1)
@@ -492,6 +562,7 @@ def test_template_vpn_fk_autocomplete_view(self):
url=self._get_autocomplete_view_path(self.app_label, "template", "vpn"),
visible=[data["vpn1"].name],
hidden=[data["vpn2"].name, data["vpn_inactive"].name],
+ superuser_hidden=[data["vpn_inactive"].name],
)
def test_vpn_queryset(self):
@@ -525,14 +596,16 @@ def test_vpn_ca_fk_queryset(self):
hidden=[data["vpn2"].ca.name, data["vpn_inactive"].ca.name],
select_widget=True,
administrator=True,
+ superuser_hidden=[data["vpn_inactive"].ca.name],
)
def test_vpn_cert_fk_queryset(self):
data = self._create_multitenancy_test_env(vpn=True)
+ self.assertIsNone(data["vpn_inactive"].cert)
self._test_multitenant_admin(
url=reverse(f"admin:{self.app_label}_vpn_add"),
visible=[data["vpn1"].cert.name, data["vpn_shared"].cert.name],
- hidden=[data["vpn2"].cert.name, data["vpn_inactive"].cert.name],
+ hidden=[data["vpn2"].cert.name],
select_widget=True,
administrator=True,
)
@@ -666,6 +739,64 @@ def test_change_group_action_skips_deactivated_device(self):
device.refresh_from_db()
self.assertIsNone(device.group)
+ def test_change_group_action_disabled_org(self):
+ path = reverse(f"admin:{self.app_label}_device_changelist")
+ org = self._get_org(org_name="default")
+ org.is_active = False
+ org.save(update_fields=["is_active"])
+ group = self._create_device_group(name="test-group", organization=org)
+ device = self._create_device(organization=org)
+ post_data = {
+ "_selected_action": [device.pk],
+ "action": "change_group",
+ "csrfmiddlewaretoken": "test",
+ "apply": True,
+ "device_group": group.pk,
+ }
+ response = self.client.post(path, post_data, follow=True)
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(
+ response, "Actions cannot modify objects of disabled organizations."
+ )
+ device.refresh_from_db()
+ self.assertIsNone(device.group)
+
+ def test_activate_device_action_disabled_org(self):
+ org = self._get_org()
+ org.is_active = False
+ org.save(update_fields=["is_active"])
+ device = self._create_device(organization=org, _is_deactivated=True)
+ path = reverse(f"admin:{self.app_label}_device_changelist")
+ data = {
+ "_selected_action": [device.pk],
+ "action": "activate_device",
+ "csrfmiddlewaretoken": "test",
+ }
+ response = self.client.post(path, data, follow=True)
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(
+ response, "Actions cannot modify objects of disabled organizations."
+ )
+ device.refresh_from_db()
+ self.assertEqual(device.is_deactivated(), True)
+
+ def test_deactivate_device_action_disabled_org(self):
+ org = self._get_org()
+ org.is_active = False
+ org.save(update_fields=["is_active"])
+ device = self._create_device(organization=org)
+ path = reverse(f"admin:{self.app_label}_device_changelist")
+ data = {
+ "_selected_action": [device.pk],
+ "action": "deactivate_device",
+ "csrfmiddlewaretoken": "test",
+ }
+ response = self.client.post(path, data, follow=True)
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, "was deactivated successfully")
+ device.refresh_from_db()
+ self.assertEqual(device.is_deactivated(), True)
+
def test_device_import_with_group_apply_templates(self):
org = self._get_org(org_name="default")
template = self._create_template(name="template")
@@ -945,6 +1076,141 @@ def test_clone_templates_only_managed_orgs(self):
self.assertNotContains(response, "
Clone templates
", html=True)
self.assertEqual(Template.objects.count(), count + 1)
+ def test_clone_templates_disabled_target_org(self):
+ path = reverse(f"admin:{self.app_label}_template_changelist")
+ template = self._create_template(organization=self._get_org())
+ disabled_org = self._create_org(name="disabled-org", is_active=False)
+ self._get_org("org_2")
+ post_data = self._get_clone_template_post_data(template)
+ post_data["organization"] = str(disabled_org.id)
+ count = Template.objects.count()
+ response = self.client.post(path, post_data, follow=True)
+ self.assertEqual(response.status_code, 200)
+ self.assertNotContains(response, "Successfully cloned selected templates")
+ self.assertContains(
+ response,
+ "Cannot clone templates: the selected organization does not exist"
+ " or is disabled.",
+ )
+ self.assertEqual(Template.objects.count(), count)
+
+ def test_device_disabled_org_admin_crud(self):
+ org = self._create_org(name="disabled-org", slug="disabled-org")
+ device = self._create_device(organization=org, name="disabled-device")
+ org.is_active = False
+ org.save(update_fields=["is_active"])
+ self._test_disabled_org_admin_crud(
+ device,
+ change_data={"name": "renamed-device"},
+ operations=("view", "change", "add"),
+ create_data={
+ "name": "new-device",
+ "organization": str(org.pk),
+ "mac_address": "00:11:22:33:44:66",
+ "key": "w1gwJxKaHcamUw62TQIPgYchwLKn3AA0",
+ "model": "",
+ "os": "",
+ "system": "",
+ "notes": "",
+ "management_ip": "127.0.0.1",
+ "hardware_id": "new-device-hardware-id",
+ },
+ )
+ with self.subTest("superuser delete, once deactivated"):
+ # DeviceAdmin.has_delete_permission additionally requires the
+ # device to be deactivated first, a precondition unrelated to
+ # the disabled-org policy; satisfy it here to isolate and
+ # confirm that a disabled organization's device stays deletable.
+ device._is_deactivated = True
+ device.save(update_fields=["_is_deactivated"])
+ urls = self._get_disabled_org_admin_urls(device)
+ self.client.force_login(self._get_admin())
+ self._test_disabled_org_admin_delete(urls["delete"], Device, device.pk)
+ self.client.logout()
+
+ def test_devicegroup_disabled_org_admin_crud(self):
+ org = self._create_org(name="disabled-org", slug="disabled-org")
+ device_group = self._create_device_group(
+ name="disabled-group", organization=org
+ )
+ org.is_active = False
+ org.save(update_fields=["is_active"])
+ self._test_disabled_org_admin_crud(
+ device_group,
+ change_data={"name": "renamed-group"},
+ create_data={
+ "name": "new-device-group",
+ "organization": str(org.pk),
+ "description": "",
+ "context": "{}",
+ "meta_data": "{}",
+ },
+ )
+
+ def test_template_disabled_org_admin_crud(self):
+ org = self._create_org(name="disabled-org", slug="disabled-org")
+ template = self._create_template(name="disabled-template", organization=org)
+ org.is_active = False
+ org.save(update_fields=["is_active"])
+ self._test_disabled_org_admin_crud(
+ template,
+ change_data={"name": "renamed-template"},
+ create_data={
+ "name": "new-template",
+ "organization": str(org.pk),
+ "backend": "netjsonconfig.OpenWrt",
+ "config": "{}",
+ },
+ )
+
+ def test_vpn_disabled_org_admin_crud(self):
+ org = self._create_org(name="disabled-org", slug="disabled-org")
+ vpn = self._create_vpn(name="disabled-vpn", organization=org)
+ org.is_active = False
+ org.save(update_fields=["is_active"])
+ self._test_disabled_org_admin_crud(
+ vpn,
+ change_data={"name": "renamed-vpn"},
+ create_data={
+ "name": "new-vpn",
+ "organization": str(org.pk),
+ "host": "vpn2.test.com",
+ "backend": "openwisp_controller.vpn_backends.OpenVpn",
+ "config": "{}",
+ "dh": vpn.dh,
+ "ca": str(vpn.ca.pk),
+ },
+ )
+
+ def test_device_disabled_org_admin_inline_readonly(self):
+ org = self._create_org(name="disabled-org", slug="disabled-org")
+ device = self._create_device(organization=org, name="disabled-device")
+ active_device = self._create_device(
+ name="active-device", mac_address="00:11:22:09:44:71"
+ )
+ org.is_active = False
+ org.save(update_fields=["is_active"])
+ model_admin = django_admin.site._registry[Device]
+ self._test_disabled_org_admin_inline_readonly(
+ model_admin, device, active_obj=active_device
+ )
+
+ def test_device_disabled_org_admin_org_field_excludes_disabled(self):
+ active_org = self._get_org(org_name="default")
+ disabled_org = self._create_org(name="disabled-org", is_active=False)
+ add_url = reverse(f"admin:{self.app_label}_device_add")
+ self._test_disabled_org_admin_org_field_excludes_disabled(
+ add_url, disabled_org, organization=active_org
+ )
+
+ def test_template_disabled_org_admin_org_field_excludes_disabled(self):
+ active_org = self._get_org(org_name="default")
+ disabled_org = self._create_org(name="disabled-org", is_active=False)
+ add_url = reverse(f"admin:{self.app_label}_template_add")
+ self._test_disabled_org_admin_org_field_excludes_disabled(
+ add_url, disabled_org, organization=active_org
+ )
+
def test_clone_templates_validation_error(self):
path = reverse(f"admin:{self.app_label}_template_changelist")
# very long name will trigger validation error
@@ -2289,7 +2555,7 @@ def _verify_template_queries(self, config, count):
path = reverse(f"admin:{self.app_label}_device_change", args=[config.device.pk])
for i in range(count):
self._create_template(name=f"template-{i}")
- expected_count = 22
+ expected_count = 23
if django.VERSION < (5, 2):
# In django version < 5.2, there is an extra SAVEPOINT query
# leading to extra RELEASE SAVEPOINT query, thus 2 extra queries
@@ -2920,9 +3186,11 @@ def test_organization_delete_warning_uses_authorized_organizations(self):
request.resolver_match = resolve(url)
request.user = user
# TODO: replace _registry with get_model_admin once Django 4.2 is dropped
- device_admin = admin.site._registry[Device]
+ device_admin = django_admin.site._registry[Device]
admin_site = SimpleNamespace(
- _registry={first_org.__class__: admin.site._registry[first_org.__class__]}
+ _registry={
+ first_org.__class__: django_admin.site._registry[first_org.__class__]
+ }
)
with (
patch.object(device_admin, "admin_site", admin_site),
@@ -2950,7 +3218,7 @@ def test_organization_delete_warning_is_checked_once(self):
request = RequestFactory().get(url)
request.resolver_match = resolve(url)
# TODO: replace _registry with get_model_admin once Django 4.2 is dropped
- device_admin = admin.site._registry[Device]
+ device_admin = django_admin.site._registry[Device]
with patch.object(
Device.objects, "filter", wraps=Device.objects.filter
) as filter_:
diff --git a/openwisp_controller/config/tests/test_api.py b/openwisp_controller/config/tests/test_api.py
index e5f2136a1..ddc0e86c5 100644
--- a/openwisp_controller/config/tests/test_api.py
+++ b/openwisp_controller/config/tests/test_api.py
@@ -9,11 +9,15 @@
from django.test.testcases import TransactionTestCase
from django.urls import reverse
from django.utils import timezone
+from rest_framework.test import APIRequestFactory
from swapper import load_model
-from openwisp_controller.config.api.serializers import BaseConfigSerializer
+from openwisp_controller.config.api.serializers import (
+ BaseConfigSerializer,
+ FilterTemplatesByOrganization,
+)
from openwisp_controller.tests.utils import TestAdminMixin
-from openwisp_users.tests.test_api import AuthenticationMixin
+from openwisp_users.tests.test_api import AuthenticationMixin, TestDisabledOrgApiMixin
from openwisp_utils.tests import capture_any_output, catch_signal
from .. import settings as app_settings
@@ -105,6 +109,7 @@ class TestConfigApi(
CreateConfigTemplateMixin,
TestVpnX509Mixin,
CreateDeviceGroupMixin,
+ TestDisabledOrgApiMixin,
AuthenticationMixin,
TestCase,
):
@@ -235,6 +240,23 @@ def execute_assertions(data):
data["config"].update({"context": {}, "config": {}})
execute_assertions(data)
+ def test_filter_templates_by_organization_excludes_disabled_org(self):
+ disabled_org = self._create_org(name="disabled-org", is_active=False)
+ disabled_template = self._create_template(
+ name="disabled-template", organization=disabled_org
+ )
+ shared_template = self._create_template(
+ name="shared-template", organization=None
+ )
+ admin = self._get_admin()
+ request = APIRequestFactory().get("/")
+ request.user = admin
+ field = FilterTemplatesByOrganization()
+ field._context = {"request": request}
+ queryset = field.get_queryset()
+ self.assertNotIn(disabled_template, queryset)
+ self.assertIn(shared_template, queryset)
+
def test_device_create_with_devicegroup(self):
self.assertEqual(Device.objects.count(), 0)
path = reverse("config_api:device_list")
@@ -251,9 +273,10 @@ def test_device_create_with_devicegroup(self):
def test_device_list_api(self):
device = self._create_device()
path = reverse("config_api:device_list")
- with patch.object(
- app_settings, "WHOIS_CONFIGURED", False
- ), self.assertNumQueries(3):
+ with (
+ patch.object(app_settings, "WHOIS_CONFIGURED", False),
+ self.assertNumQueries(3),
+ ):
r = self.client.get(path)
self.assertEqual(r.status_code, 200)
with self.subTest("device list should show most recent first"):
@@ -399,9 +422,10 @@ def test_device_filter_templates(self):
def test_device_detail_api(self):
d1 = self._create_device()
path = reverse("config_api:device_detail", args=[d1.pk])
- with patch.object(
- app_settings, "WHOIS_CONFIGURED", False
- ), self.assertNumQueries(2):
+ with (
+ patch.object(app_settings, "WHOIS_CONFIGURED", False),
+ self.assertNumQueries(2),
+ ):
r = self.client.get(path)
self.assertEqual(r.status_code, 200)
self.assertEqual(r.data["config"], None)
@@ -411,9 +435,10 @@ def test_device_detail_config_api(self):
d1 = self._create_device()
self._create_config(device=d1)
path = reverse("config_api:device_detail", args=[d1.pk])
- with patch.object(
- app_settings, "WHOIS_CONFIGURED", False
- ), self.assertNumQueries(3):
+ with (
+ patch.object(app_settings, "WHOIS_CONFIGURED", False),
+ self.assertNumQueries(3),
+ ):
r = self.client.get(path)
self.assertEqual(r.status_code, 200)
self.assertNotEqual(r.data["config"], None)
@@ -586,6 +611,111 @@ def test_device_activate_api(self):
device.refresh_from_db()
self.assertEqual(device.is_deactivated(), False)
+ def test_device_activate_deactivate_api_disabled_org(self):
+ org = self._get_org()
+ org.is_active = False
+ org.save(update_fields=["is_active"])
+ deactivated_device = self._create_device(
+ name="deactivated",
+ organization=org,
+ mac_address="00:11:22:09:44:55",
+ _is_deactivated=True,
+ )
+ active_device = self._create_device(
+ name="active", organization=org, mac_address="00:11:22:09:44:56"
+ )
+ activate_path = reverse(
+ "config_api:device_activate", args=[deactivated_device.pk]
+ )
+ deactivate_path = reverse(
+ "config_api:device_deactivate", args=[active_device.pk]
+ )
+ # Reactivation is blocked while the organization is disabled, but
+ # deactivation remains allowed so operators can remediate devices missed
+ # by the organization-wide deactivation task.
+ activate_response = self.client.post(activate_path)
+ deactivate_response = self.client.post(deactivate_path)
+ self.assertEqual(activate_response.status_code, 403)
+ self.assertEqual(deactivate_response.status_code, 200)
+
+ def test_device_disabled_org_api_crud(self):
+ org = self._create_org(name="disabled-org", slug="disabled-org")
+ device = self._create_device(organization=org, name="disabled-device")
+ org.is_active = False
+ org.save(update_fields=["is_active"])
+ self._test_disabled_org_api_crud(
+ device,
+ detail_url=reverse("config_api:device_detail", args=[device.pk]),
+ list_url=reverse("config_api:device_list"),
+ create_payload={
+ **self._device_data,
+ "organization": str(org.pk),
+ "mac_address": "00:11:22:09:44:70",
+ },
+ update_payload={"name": "renamed-device"},
+ operations=("list", "retrieve", "create", "update"),
+ )
+ with self.subTest("superuser delete, once deactivated"):
+ # Deleting a device additionally requires the device
+ # to be deactivated first, a precondition unrelated
+ # to the disabled-org policy; satisfy it here to isolate and
+ # confirm that a disabled organization's device is deletable.
+ device._is_deactivated = True
+ device.save(update_fields=["_is_deactivated"])
+ admin = self._get_admin()
+ auth = self._disabled_org_api_auth(admin)
+ self._test_disabled_org_api_delete(
+ reverse("config_api:device_detail", args=[device.pk]),
+ auth,
+ Device,
+ device.pk,
+ )
+
+ def test_devicegroup_disabled_org_api_crud(self):
+ org = self._create_org(name="disabled-org", slug="disabled-org")
+ device_group = self._create_device_group(
+ name="disabled-group", organization=org
+ )
+ org.is_active = False
+ org.save(update_fields=["is_active"])
+ self._test_disabled_org_api_crud(
+ device_group,
+ detail_url=reverse("config_api:devicegroup_detail", args=[device_group.pk]),
+ list_url=reverse("config_api:devicegroup_list"),
+ create_payload={**self._devicegroup_data, "organization": str(org.pk)},
+ update_payload={"name": "renamed-group"},
+ )
+
+ def test_template_disabled_org_api_crud(self):
+ org = self._create_org(name="disabled-org", slug="disabled-org")
+ template = self._create_template(name="disabled-template", organization=org)
+ org.is_active = False
+ org.save(update_fields=["is_active"])
+ self._test_disabled_org_api_crud(
+ template,
+ detail_url=reverse("config_api:template_detail", args=[template.pk]),
+ list_url=reverse("config_api:template_list"),
+ create_payload={**self._template_data, "organization": str(org.pk)},
+ update_payload={"name": "renamed-template"},
+ )
+
+ def test_vpn_disabled_org_api_crud(self):
+ org = self._create_org(name="disabled-org", slug="disabled-org")
+ vpn = self._create_vpn(name="disabled-vpn", organization=org)
+ org.is_active = False
+ org.save(update_fields=["is_active"])
+ self._test_disabled_org_api_crud(
+ vpn,
+ detail_url=reverse("config_api:vpn_detail", args=[vpn.pk]),
+ list_url=reverse("config_api:vpn_list"),
+ create_payload={
+ **self._vpn_data,
+ "organization": str(org.pk),
+ "ca": str(vpn.ca.pk),
+ },
+ update_payload={"name": "renamed-vpn"},
+ )
+
def test_device_delete_api(self):
self._create_template(required=True)
device = self._create_device()
diff --git a/openwisp_controller/config/tests/test_config.py b/openwisp_controller/config/tests/test_config.py
index be157d984..a282e56d8 100644
--- a/openwisp_controller/config/tests/test_config.py
+++ b/openwisp_controller/config/tests/test_config.py
@@ -476,6 +476,19 @@ def test_multiple_vpn_clients(self):
config.templates.set((template1, template2))
self.assertEqual(config.vpnclient_set.count(), 2)
+ def test_manage_vpn_clients_skips_disabled_org(self):
+ org = self._get_org()
+ vpn = self._create_vpn(organization=org)
+ template = self._create_template(
+ name="test-network", type="vpn", vpn=vpn, organization=org
+ )
+ config = self._create_config(device=self._create_device(organization=org))
+ org.is_active = False
+ org.save(update_fields=["is_active"])
+ config.templates.add(template)
+ config.save()
+ self.assertEqual(config.vpnclient_set.count(), 0)
+
def test_create_cert(self):
vpn = self._create_vpn()
t = self._create_template(
diff --git a/openwisp_controller/config/tests/test_controller.py b/openwisp_controller/config/tests/test_controller.py
index 2fb85fe2a..9365cd66e 100644
--- a/openwisp_controller/config/tests/test_controller.py
+++ b/openwisp_controller/config/tests/test_controller.py
@@ -13,6 +13,7 @@
from openwisp_utils.tests import capture_any_output, catch_signal
from .. import settings as app_settings
+from .. import tasks
from ..base.base import logger as base_config_logger
from ..controller.views import DeviceChecksumView, VpnChecksumView
from ..controller.views import logger as controller_views_logger
@@ -63,17 +64,6 @@ def _get_reregistration_payload(self, device, **kwargs):
data.update(**kwargs)
return data
-
-class TestController(
- TestRegistrationMixin, CreateConfigTemplateMixin, TestVpnX509Mixin, TestCase
-):
- """
- tests for config.controller
- """
-
- def _check_header(self, response):
- self.assertEqual(response["X-Openwisp-Controller"], "true")
-
def _test_view_organization_disabled(
self, obj, url, http_method="get", org=None, data=None
):
@@ -89,6 +79,17 @@ def _test_view_organization_disabled(
response = method(url, {"key": obj.key})
self.assertEqual(response.status_code, 404)
+
+class TestController(
+ TestRegistrationMixin, CreateConfigTemplateMixin, TestVpnX509Mixin, TestCase
+):
+ """
+ tests for config.controller
+ """
+
+ def _check_header(self, response):
+ self.assertEqual(response["X-Openwisp-Controller"], "true")
+
def test_device_checksum(self):
d = self._create_device_config()
c = d.config
@@ -269,12 +270,6 @@ def test_vpn_checksum_405(self):
)
self.assertEqual(response.status_code, 405)
- def test_vpn_checksum_org_disabled(self):
- vpn = self._create_vpn(organization=self._get_org())
- self._test_view_organization_disabled(
- vpn, reverse("controller:vpn_checksum", args=[vpn.pk])
- )
-
def test_vpn_get_object_cached(self):
vpn = self._create_vpn()
view = VpnChecksumView()
@@ -339,13 +334,6 @@ def test_vpn_download_config_405(self):
)
self.assertEqual(response.status_code, 405)
- def test_vpn_download_config_org_disabled(self):
- vpn = self._create_vpn(organization=self._get_org())
- self._test_view_organization_disabled(
- vpn,
- reverse("controller:vpn_download_config", args=[vpn.pk]),
- )
-
def test_register(self, **kwargs):
options = {
"hardware_id": "1234",
@@ -1234,22 +1222,59 @@ def test_register_403_disabled_org(self):
)
self.assertContains(response, "error: unrecognized secret", status_code=403)
- def test_checksum_404_disabled_org(self):
- org = self._create_org()
- c = self._create_config(organization=org)
- # Cache checksum
- response = self.client.get(
- reverse("controller:device_checksum", args=[c.device.pk]),
- {"key": c.device.key},
+ @capture_any_output()
+ def test_register_reregistration_403_disabled_org(self):
+ org = self._get_org()
+ device = self._create_device(
+ organization=org,
+ key=TEST_CONSISTENT_KEY,
+ mac_address=TEST_MACADDR,
+ name=TEST_MACADDR_NAME,
)
- self.assertEqual(response.status_code, 200)
+ self._create_config(device=device)
org.is_active = False
- org.save()
- response = self.client.get(
- reverse("controller:device_checksum", args=[c.device.pk]),
- {"key": c.device.key},
+ org.save(update_fields=["is_active"])
+ payload = self._get_reregistration_payload(device, name=TEST_MACADDR_NAME)
+ response = self.client.post(self.register_url, payload)
+ self.assertContains(response, "error: unrecognized secret", status_code=403)
+
+ @capture_any_output()
+ def test_register_reregistration_403_cross_tenant_secret(self):
+ org = self._get_org()
+ device = self._create_device(
+ organization=org,
+ key=TEST_CONSISTENT_KEY,
+ mac_address=TEST_MACADDR,
+ name=TEST_MACADDR_NAME,
)
- self.assertEqual(response.status_code, 404)
+ self._create_config(device=device)
+ org.is_active = False
+ org.save(update_fields=["is_active"])
+ other_org = self._create_org(
+ shared_secret="other_org_secret", name="other-org", slug="other-org"
+ )
+ payload = self._get_reregistration_payload(device, name=TEST_MACADDR_NAME)
+ payload["secret"] = other_org.config_settings.shared_secret
+ response = self.client.post(self.register_url, payload)
+ self.assertContains(response, "error: unrecognized secret", status_code=403)
+
+ @capture_any_output()
+ def test_register_reregistration_403_cross_tenant_secret_both_active(self):
+ org = self._get_org()
+ device = self._create_device(
+ organization=org,
+ key=TEST_CONSISTENT_KEY,
+ mac_address=TEST_MACADDR,
+ name=TEST_MACADDR_NAME,
+ )
+ self._create_config(device=device)
+ other_org = self._create_org(
+ shared_secret="other_org_secret", name="other-org", slug="other-org"
+ )
+ payload = self._get_reregistration_payload(device, name=TEST_MACADDR_NAME)
+ payload["secret"] = other_org.config_settings.shared_secret
+ response = self.client.post(self.register_url, payload)
+ self.assertContains(response, "error: unrecognized secret", status_code=403)
def test_download_config_404_disabled_org(self):
org = self._create_org(is_active=False)
@@ -1448,6 +1473,71 @@ def _test_deactivating_deactivated_device_view(
config.refresh_from_db()
self.assertEqual(config.status, "deactivated")
+ def test_checksum_404_disabled_org(self):
+ org = self._create_org()
+ config = self._create_config(organization=org)
+ device = config.device
+ # Cache checksum
+ response = self.client.get(
+ reverse("controller:device_checksum", args=[device.pk]),
+ {"key": device.key},
+ )
+ self.assertEqual(response.status_code, 200)
+ org.is_active = False
+ org.save()
+ response = self.client.get(
+ reverse("controller:device_checksum", args=[device.pk]),
+ {"key": device.key},
+ )
+ self.assertEqual(response.status_code, 404)
+
+ def test_report_status_deactivating_allowed_disabled_org(self):
+ org = self._get_org()
+ self._create_template(required=True, organization=org)
+ device = self._create_device_config(device_opts={"organization": org})
+ c = device.config
+ org.is_active = False
+ org.save(update_fields=["is_active"])
+ tasks.deactivate_organization_devices(org.id)
+ c.refresh_from_db()
+ self.assertEqual(c.status, "deactivating")
+
+ with self.subTest("download configuration"):
+ response = self.client.get(
+ reverse("controller:device_download_config", args=[device.pk]),
+ {"key": device.key},
+ )
+ self.assertEqual(response.status_code, 200)
+
+ with self.subTest("download checksum"):
+ response = self.client.get(
+ reverse("controller:device_checksum", args=[device.pk]),
+ {"key": device.key},
+ )
+ self.assertEqual(response.status_code, 200)
+
+ with self.subTest("report status"):
+ response = self.client.post(
+ reverse("controller:device_report_status", args=[device.pk]),
+ {"key": device.key, "status": "applied"},
+ )
+ self.assertEqual(response.status_code, 200)
+ c.refresh_from_db()
+ self.assertEqual(c.status, "deactivated")
+
+ def test_vpn_checksum_org_disabled(self):
+ vpn = self._create_vpn(organization=self._get_org())
+ self._test_view_organization_disabled(
+ vpn, reverse("controller:vpn_checksum", args=[vpn.pk])
+ )
+
+ def test_vpn_download_config_org_disabled(self):
+ vpn = self._create_vpn(organization=self._get_org())
+ self._test_view_organization_disabled(
+ vpn,
+ reverse("controller:vpn_download_config", args=[vpn.pk]),
+ )
+
def test_device_config_deactivated_checksum(self):
self._test_deactivating_deactivated_device_view("device_checksum")
diff --git a/openwisp_controller/config/tests/test_device.py b/openwisp_controller/config/tests/test_device.py
index ba15c0103..4d55227a3 100644
--- a/openwisp_controller/config/tests/test_device.py
+++ b/openwisp_controller/config/tests/test_device.py
@@ -482,6 +482,25 @@ def test_manage_devices_group_templates_skips_deactivated_devices(self):
# Status must remain "deactivated" — no config push is initiated.
self.assertEqual(device.config.status, "deactivated")
+ def test_manage_devices_group_templates_skips_disabled_org(self):
+ org = self._get_org()
+ old_template = self._create_template(name="old-template", organization=org)
+ new_template = self._create_template(name="new-template", organization=org)
+ old_group = self._create_device_group(name="old-group", organization=org)
+ new_group = self._create_device_group(name="new-group", organization=org)
+ old_group.templates.add(old_template)
+ new_group.templates.add(new_template)
+ device = self._create_device(name="test", organization=org, group=old_group)
+ self.assertEqual(device.config.templates.count(), 1)
+ org.is_active = False
+ org.save(update_fields=["is_active"])
+ Device.manage_devices_group_templates(device.pk, old_group.pk, new_group.pk)
+ device.config.refresh_from_db()
+ # Devices of a disabled organization are skipped: their templates
+ # must not be updated while the organization is disabled.
+ self.assertEqual(device.config.templates.count(), 1)
+ self.assertNotIn(new_template, device.config.templates.all())
+
@mock.patch.object(app_settings, "WHOIS_CONFIGURED", True)
def test_changed_checked_fields_no_duplicates(self):
"""Ensure `_changed_checked_fields` contains `last_ip` only once.
@@ -583,6 +602,16 @@ def test_create_default_config_existing(self):
self.assertEqual(device.config.context, {"ssid": "test"})
self.assertEqual(device.config.config, {"general": {}})
+ def test_create_default_config_skipped_for_disabled_org(self):
+ org = self._get_org()
+ template = self._create_template()
+ group = self._create_device_group(organization=org)
+ group.templates.add(template)
+ org.is_active = False
+ org.save(update_fields=["is_active"])
+ device = self._create_device(organization=org, group=group)
+ self.assertFalse(hasattr(device, "config"))
+
class TestTransactionDevice(
CreateConfigTemplateMixin,
diff --git a/openwisp_controller/config/tests/test_device_group.py b/openwisp_controller/config/tests/test_device_group.py
index 476a2f1f6..a9e343b52 100644
--- a/openwisp_controller/config/tests/test_device_group.py
+++ b/openwisp_controller/config/tests/test_device_group.py
@@ -11,6 +11,7 @@
from ..signals import group_templates_changed
from .utils import CreateDeviceGroupMixin, CreateTemplateMixin
+Device = load_model("config", "Device")
DeviceGroup = load_model("config", "DeviceGroup")
@@ -61,3 +62,21 @@ def test_device_group_signals(self):
raw=False,
using="default",
)
+
+ def test_manage_group_templates_skips_disabled_org(self):
+ org = self._get_org()
+ template = self._create_template(name="new-template", organization=org)
+ device_group = self._create_device_group(organization=org)
+ device = Device(
+ name="test-device",
+ organization=org,
+ mac_address="00:11:22:33:44:55",
+ group=device_group,
+ )
+ device.full_clean()
+ device.save()
+ org.is_active = False
+ org.save(update_fields=["is_active"])
+ DeviceGroup.manage_group_templates(device_group.pk, [], [template.pk])
+ device = Device.objects.get(pk=device.pk)
+ self.assertEqual(hasattr(device, "config"), False)
diff --git a/openwisp_controller/config/tests/test_handlers.py b/openwisp_controller/config/tests/test_handlers.py
index de4981224..f807f35f2 100644
--- a/openwisp_controller/config/tests/test_handlers.py
+++ b/openwisp_controller/config/tests/test_handlers.py
@@ -1,37 +1,110 @@
-from unittest.mock import patch
+from unittest.mock import DEFAULT, patch
-from django.test import TestCase
-
-from openwisp_users.tests.utils import TestOrganizationMixin
+from django.test import TransactionTestCase
+from swapper import load_model
from .. import tasks
+from .utils import CreateConfigMixin, Device
+
+Organization = load_model("openwisp_users", "Organization")
-class TestHandlers(TestOrganizationMixin, TestCase):
- @patch.object(tasks.invalidate_controller_views_cache, "delay")
- def test_organization_disabled_handler(self, mocked_task):
+class TestHandlers(CreateConfigMixin, TransactionTestCase):
+ @patch("openwisp_controller.config.handlers.chain")
+ def test_organization_disabled_handler(self, mocked_chain):
with self.subTest("Test task not executed on creating active orgs"):
org = self._create_org()
- mocked_task.assert_not_called()
+ mocked_chain.assert_not_called()
with self.subTest("Test task executed on changing active to inactive org"):
+ observed = {}
+
+ def _record_db_state(*args, **kwargs):
+ observed["is_active"] = (
+ Organization.objects.only("is_active").get(pk=org.pk).is_active
+ )
+ return DEFAULT
+
+ mocked_chain.side_effect = _record_db_state
org.is_active = False
org.save()
- mocked_task.assert_called_once_with(str(org.id))
+ mocked_chain.assert_called_once_with(
+ tasks.deactivate_organization_devices.s(str(org.id)),
+ tasks.invalidate_controller_views_cache.si(str(org.id)),
+ )
+ mocked_chain.return_value.delay.assert_called_once()
+ # Dispatch happens only after the is_active write has committed.
+ self.assertEqual(observed["is_active"], False)
+ mocked_chain.side_effect = None
- mocked_task.reset_mock()
+ mocked_chain.reset_mock()
with self.subTest("Test task not executed on saving inactive org"):
org.name = "Changed named"
org.save()
- mocked_task.assert_not_called()
+ mocked_chain.assert_not_called()
with self.subTest("Test task not executed on creating inactive org"):
- inactive_org = self._create_org(
- is_active=False, name="inactive", slug="inactive"
- )
- mocked_task.assert_not_called()
+ self._create_org(is_active=False, name="inactive", slug="inactive")
+ mocked_chain.assert_not_called()
with self.subTest("Test task not executed on changing inactive to active org"):
- inactive_org.is_active = True
- inactive_org.save()
- mocked_task.assert_not_called()
+ org.is_active = True
+ org.save()
+ mocked_chain.assert_not_called()
+
+ def test_deactivate_organization_devices(self):
+ org = self._create_org()
+ device = self._create_device(organization=org)
+ config = self._create_config(device=device)
+ device = config.device
+
+ with self.subTest("Devices are deactivated when org gets disabled"):
+ org.is_active = False
+ org.save()
+ tasks.deactivate_organization_devices(org.id)
+ device.refresh_from_db()
+ config.refresh_from_db()
+ self.assertEqual(device._is_deactivated, True)
+ self.assertIn(config.status, ("deactivating", "deactivated"))
+
+ with self.subTest("Re-enabling org does not reactivate devices"):
+ org.is_active = True
+ org.save()
+ device.refresh_from_db()
+ self.assertEqual(device._is_deactivated, True)
+
+ def test_deactivate_organization_devices_partial_failure(self):
+ org = self._create_org()
+ failing_device = self._create_device(
+ organization=org, name="failing-device", mac_address="00:11:22:33:44:01"
+ )
+ self._create_config(device=failing_device)
+ ok_device = self._create_device(
+ organization=org, name="ok-device", mac_address="00:11:22:33:44:02"
+ )
+ self._create_config(device=ok_device)
+ with patch("openwisp_controller.config.handlers.chain"):
+ org.is_active = False
+ org.save()
+ original_deactivate = Device.deactivate
+
+ def _deactivate_side_effect(self, *args, **kwargs):
+ if self.pk == failing_device.pk:
+ raise Exception("Simulated deactivation failure")
+ return original_deactivate(self, *args, **kwargs)
+
+ with patch.object(tasks, "logger") as mocked_logger:
+ with patch.object(
+ Device,
+ "deactivate",
+ autospec=True,
+ side_effect=_deactivate_side_effect,
+ ):
+ tasks.deactivate_organization_devices(org.id)
+ mocked_logger.exception.assert_called_once_with(
+ "Failed to deactivate device %s while disabling organization %s",
+ failing_device.pk,
+ org.id,
+ )
+ ok_device.refresh_from_db()
+ self.assertEqual(ok_device._is_deactivated, True)
diff --git a/openwisp_controller/config/tests/test_vpn.py b/openwisp_controller/config/tests/test_vpn.py
index e1611e740..a603d56b9 100644
--- a/openwisp_controller/config/tests/test_vpn.py
+++ b/openwisp_controller/config/tests/test_vpn.py
@@ -426,6 +426,27 @@ def test_auto_create_cert_with_long_device_name(self):
self.assertEqual(cert.count(), 1)
self.assertEqual(cert.first().common_name[:-9], client._get_common_name()[:-9])
+ def test_auto_create_cert_skipped_for_disabled_org(self):
+ org = self._get_org()
+ org.is_active = False
+ org.save(update_fields=["is_active"])
+ vpn = self._create_vpn(organization=org)
+ self.assertIsNone(vpn.cert)
+
+ def test_vpnclient_auto_create_cert_skipped_for_disabled_org(self):
+ org = self._get_org()
+ vpn = self._create_vpn(organization=org)
+ d = self._create_device(organization=org)
+ c = self._create_config(device=d)
+ org.is_active = False
+ org.save(update_fields=["is_active"])
+ client = VpnClient(
+ vpn=vpn, config=c, auto_cert=True, template=self._create_template()
+ )
+ client.full_clean()
+ client.save()
+ self.assertIsNone(client.cert)
+
@mock.patch.object(Vpn, "dhparam", side_effect=SoftTimeLimitExceeded)
def test_update_vpn_dh_timeout(self, dhparam):
vpn = self._create_vpn(dh="")
@@ -512,6 +533,14 @@ def test_placeholder_dh_set(self, delay):
self.assertEqual(vpn.dh, Vpn._placeholder_dh)
delay.assert_called_once_with(vpn.pk)
+ @mock.patch.object(create_vpn_dh, "delay")
+ def test_create_vpn_dh_skipped_for_disabled_org(self, delay):
+ org = self._get_org()
+ org.is_active = False
+ org.save(update_fields=["is_active"])
+ self._create_vpn(dh="", organization=org)
+ delay.assert_not_called()
+
@mock.patch.object(Vpn, "dhparam")
def test_update_vpn_dh(self, dhparam):
dhparam.return_value = self._dh
@@ -629,6 +658,15 @@ def test_cert_renew_cascades_to_client_config(self):
class TestWireguard(BaseTestVpn, TestWireguardVpnMixin, TestCase):
+ def test_wireguard_keys_and_ip_skipped_for_disabled_org(self):
+ org = self._get_org()
+ org.is_active = False
+ org.save(update_fields=["is_active"])
+ vpn = self._create_wireguard_vpn(organization=org)
+ self.assertEqual(vpn.public_key, "")
+ self.assertEqual(vpn.private_key, "")
+ self.assertIsNone(vpn.ip)
+
def test_wireguard_config_creation(self):
vpn = self._create_wireguard_vpn()
@@ -817,6 +855,23 @@ def test_trigger_vpn_server_endpoint_invalid_vpn_id(self):
f"VPN Server UUID: {vpn_id} does not exist."
)
+ @mock.patch("openwisp_controller.config.tasks.requests.post")
+ def test_trigger_vpn_server_endpoint_disabled_org(self, mocked_post):
+ # The webhook must still fire for a disabled organization's VPN so that
+ # peer removals triggered by cascading device deactivation reach the server.
+ # Peer additions are already prevented upstream because disabled
+ # organizations cannot create new devices or VPN clients.
+ org = self._get_org()
+ vpn = self._create_vpn(organization=org)
+ org.is_active = False
+ org.save(update_fields=["is_active"])
+ trigger_vpn_server_endpoint(
+ endpoint="https://vpn_updater",
+ auth_token="secret",
+ vpn_id=str(vpn.id),
+ )
+ mocked_post.assert_called_once()
+
class TestWireguardTransaction(BaseTestVpn, TestWireguardVpnMixin, TransactionTestCase):
mock_response = mock.Mock(spec=requests.Response)
@@ -973,6 +1028,26 @@ def test_vpn_peers_changed(self):
class TestVxlan(BaseTestVpn, TestVxlanWireguardVpnMixin, TestCase):
+ def test_vpnclient_vni_skipped_for_disabled_org(self):
+ tunnel, subnet = self._create_vxlan_tunnel()
+ org = tunnel.organization
+ template = self._create_template(
+ name="vxlan-wireguard",
+ type="vpn",
+ vpn=tunnel,
+ organization=org,
+ auto_cert=True,
+ )
+ device = self._create_device(organization=org)
+ config = self._create_config(device=device)
+ org.is_active = False
+ org.save(update_fields=["is_active"])
+ client = VpnClient(vpn=tunnel, config=config, auto_cert=True, template=template)
+ client.full_clean()
+ client.save()
+ client.refresh_from_db()
+ self.assertIsNone(client.vni)
+
def test_vxlan_config_creation(self):
tunnel, subnet = self._create_vxlan_tunnel()
template = self._create_template(
@@ -1200,6 +1275,22 @@ def _set_subprocess_mock(self, mock_sub):
mock_stdout.stdout.decode.return_value = self._TEST_ZT_MEMBER_CONFIG["identity"]
mock_sub.run.return_value = mock_stdout
+ @mock.patch(_ZT_SERVICE_REQUESTS)
+ def test_zerotier_network_and_ip_skipped_for_disabled_org(self, mock_requests):
+ # Host validation (a general full_clean() check, unrelated to
+ # organization state) still performs a single GET request; only
+ # the save()-time network/IP provisioning must be skipped.
+ mock_requests.get.side_effect = [
+ self._get_mock_response(200, response=self._TEST_ZT_NODE_CONFIG)
+ ]
+ org = self._get_org()
+ org.is_active = False
+ org.save(update_fields=["is_active"])
+ vpn = self._create_zerotier_vpn(organization=org)
+ self.assertEqual(vpn.network_id, "")
+ self.assertIsNone(vpn.ip)
+ mock_requests.post.assert_not_called()
+
@mock.patch(_ZT_SERVICE_REQUESTS)
def test_zerotier_config_creation(self, mock_requests):
mock_requests.get.side_effect = [
@@ -1235,6 +1326,31 @@ def test_zerotier_config_creation(self, mock_requests):
self.assertIn("network_id", context_keys)
self.assertIn("network_name", context_keys)
+ @mock.patch(_ZT_GENERATE_IDENTITY_SUBPROCESS)
+ @mock.patch(_ZT_SERVICE_REQUESTS)
+ def test_vpnclient_secret_skipped_for_disabled_org(
+ self, mock_requests, mock_subprocess
+ ):
+ mock_requests.get.side_effect = [
+ self._get_mock_response(200, response=self._TEST_ZT_NODE_CONFIG)
+ ]
+ mock_requests.post.side_effect = [self._get_mock_response(200)]
+ org = self._get_org()
+ vpn = self._create_zerotier_vpn(organization=org)
+ template = self._create_template(
+ name="zerotier", type="vpn", vpn=vpn, organization=org, auto_cert=True
+ )
+ device = self._create_device(organization=org)
+ config = self._create_config(device=device)
+ org.is_active = False
+ org.save(update_fields=["is_active"])
+ client = VpnClient(vpn=vpn, config=config, auto_cert=True, template=template)
+ client.full_clean()
+ client.save()
+ client.refresh_from_db()
+ self.assertEqual(client.secret, "")
+ mock_subprocess.run.assert_not_called()
+
@mock.patch(_ZT_GENERATE_IDENTITY_SUBPROCESS)
@mock.patch(_ZT_SERVICE_REQUESTS)
def test_zerotier_auto_cert_false(self, mock_requests, mock_subprocess):
diff --git a/openwisp_controller/config/whois/tasks.py b/openwisp_controller/config/whois/tasks.py
index b95803025..88cb41bf6 100644
--- a/openwisp_controller/config/whois/tasks.py
+++ b/openwisp_controller/config/whois/tasks.py
@@ -65,7 +65,7 @@ def fetch_whois_details(self, device_pk, initial_ip_address):
"""
Device = load_model("config", "Device")
WHOISInfo = load_model("config", "WHOISInfo")
- device = Device.objects.filter(pk=device_pk).first()
+ device = Device.objects.select_related("organization").filter(pk=device_pk).first()
if not device:
logger.warning(f"Device {device_pk} not found, skipping WHOIS lookup")
return
@@ -74,6 +74,7 @@ def fetch_whois_details(self, device_pk, initial_ip_address):
ip_address = normalize_ip(initial_ip_address)
if (
device.is_deactivated()
+ or not device.organization.is_active
or normalize_ip(device.last_ip) != ip_address
or not whois_service.is_valid_public_ip_address(ip_address)
or not whois_service.is_whois_enabled
@@ -88,10 +89,16 @@ def fetch_whois_details(self, device_pk, initial_ip_address):
fetched_details = whois_service.process_whois_details(ip_address)
with transaction.atomic():
- device = Device.objects.select_for_update().filter(pk=device_pk).first()
+ device = (
+ Device.objects.select_for_update(of=("self",))
+ .select_related("organization")
+ .filter(pk=device_pk)
+ .first()
+ )
if (
not device
or device.is_deactivated()
+ or not device.organization.is_active
or normalize_ip(device.last_ip) != ip_address
or not device.whois_service.is_whois_enabled
):
diff --git a/openwisp_controller/config/whois/tests/tests.py b/openwisp_controller/config/whois/tests/tests.py
index 5c3fbb18d..85472ca2f 100644
--- a/openwisp_controller/config/whois/tests/tests.py
+++ b/openwisp_controller/config/whois/tests/tests.py
@@ -1058,6 +1058,18 @@ def test_fetch_details_skips_when_deactivated(self, mock_client, mock_info):
)
mock_client.assert_not_called()
+ @mock.patch.object(app_settings, "WHOIS_CONFIGURED", True)
+ @mock.patch(_WHOIS_GEOIP_CLIENT)
+ def test_fetch_details_skips_when_org_disabled(self, mock_client):
+ whois_obj = self._create_whois_info(ip_address="8.8.8.8")
+ device = self._create_device(last_ip=whois_obj.ip_address)
+ device.organization.is_active = False
+ device.organization.save(update_fields=["is_active"])
+ fetch_whois_details(
+ device_pk=device.pk, initial_ip_address=whois_obj.ip_address
+ )
+ mock_client.assert_not_called()
+
@mock.patch.object(app_settings, "WHOIS_CONFIGURED", True)
@mock.patch(_WHOIS_GEOIP_CLIENT)
def test_fetch_details_record_already_exists(self, mock_client):
diff --git a/openwisp_controller/connection/api/serializers.py b/openwisp_controller/connection/api/serializers.py
index 142c1c30a..4ec1335a8 100644
--- a/openwisp_controller/connection/api/serializers.py
+++ b/openwisp_controller/connection/api/serializers.py
@@ -21,7 +21,13 @@ def validate(self, data):
return super().validate(data)
-class CommandSerializer(ValidatedDeviceFieldSerializer):
+class CommandSerializer(FilterSerializerByOrgManaged, ValidatedDeviceFieldSerializer):
+ # ``Command`` has no direct ``organization`` field (only ``device``),
+ # and ``connection``'s queryset (``DeviceConnection``) doesn't either;
+ # this tells ``FilterSerializerByOrganization.filter_fields`` how to
+ # reach it, mirroring ``DeviceConnectionSerializer`` below.
+ organization_field = "device__organization"
+
input = serializers.JSONField(
allow_null=True,
help_text=mark_safe(
diff --git a/openwisp_controller/connection/api/views.py b/openwisp_controller/connection/api/views.py
index 6af1270c7..191aa40fa 100644
--- a/openwisp_controller/connection/api/views.py
+++ b/openwisp_controller/connection/api/views.py
@@ -34,6 +34,7 @@ class BaseCommandView(RelatedDeviceProtectedAPIMixin):
model = Command
queryset = Command.objects.prefetch_related("device")
serializer_class = CommandSerializer
+ select_related_organization = False
def get_permissions(self):
return super().get_permissions() + [RelatedDeviceModelPermission()]
@@ -41,7 +42,7 @@ def get_permissions(self):
def get_parent_queryset(self):
return Device.objects.filter(
pk=self.kwargs["device_id"],
- )
+ ).select_related("organization")
def get_queryset(self):
return (
@@ -112,6 +113,7 @@ class BaseDeviceConnection(
model = DeviceConnection
serializer_class = DeviceConnectionSerializer
queryset = DeviceConnection.objects.prefetch_related("device")
+ select_related_organization = False
def get_queryset(self):
return (
@@ -127,7 +129,9 @@ def get_serializer_context(self):
return context
def get_parent_queryset(self):
- return Device.objects.filter(pk=self.kwargs["device_id"])
+ return Device.objects.filter(pk=self.kwargs["device_id"]).select_related(
+ "organization"
+ )
class DeviceConnectionListCreateView(BaseDeviceConnection, ListCreateAPIView):
diff --git a/openwisp_controller/connection/base/models.py b/openwisp_controller/connection/base/models.py
index 17a06f7bb..437824c51 100644
--- a/openwisp_controller/connection/base/models.py
+++ b/openwisp_controller/connection/base/models.py
@@ -381,6 +381,14 @@ def connect(self):
# through so the final cleared configuration can still be pushed.
if self.device.is_fully_deactivated():
raise RuntimeError(_("Device is deactivated"))
+ # Refuse devices of a disabled organization, unless deactivation is
+ # already in progress (device.is_deactivated() True): that case must
+ # stay allowed so the final cleared configuration can still be pushed.
+ if (
+ not self.device.organization.is_active
+ and not self.device.is_deactivated()
+ ):
+ raise RuntimeError(_("Organization is disabled"))
self.connector_instance.connect()
except Exception as e:
self.is_working = False
@@ -592,7 +600,10 @@ def execute(self):
# is still pending.
if self.device.is_fully_deactivated():
self.status = "failed"
- self._add_output("Device is deactivated.")
+ self._add_output(_("Device is deactivated."))
+ elif not self.device.organization.is_active:
+ self.status = "failed"
+ self._add_output(_("Organization is disabled."))
else:
exit_code = self._exec_command()
# if output is None, the commands couldn't execute
diff --git a/openwisp_controller/connection/tests/test_admin.py b/openwisp_controller/connection/tests/test_admin.py
index 379346bc2..34845f41a 100644
--- a/openwisp_controller/connection/tests/test_admin.py
+++ b/openwisp_controller/connection/tests/test_admin.py
@@ -1,6 +1,7 @@
import json
from unittest.mock import patch
+from django.contrib import admin
from django.contrib.auth.models import Permission
from django.test import TestCase, override_settings
from django.urls import reverse
@@ -11,6 +12,7 @@
from ... import settings as module_settings
from ...tests import _get_updated_templates_settings
from ...tests.utils import TestAdminMixin
+from ..admin import CommandWritableInline, DeviceConnectionInline
from ..connectors.ssh import Ssh
from ..widgets import CredentialsSchemaWidget
from .utils import CreateConnectionsMixin
@@ -95,6 +97,33 @@ def test_connection_credentials_fk_queryset(self):
visible=[str(data["cred1"].name) + str(" (SSH)")],
hidden=[str(data["cred2"].name) + str(" (SSH)"), data["cred3_inactive"]],
select_widget=True,
+ superuser_hidden=[data["cred3_inactive"]],
+ )
+
+ def test_credentials_disabled_org_admin_crud(self):
+ org = self._create_org(name="disabled-org", slug="disabled-org")
+ credentials = self._create_credentials(
+ organization=org, name="disabled-credentials"
+ )
+ org.is_active = False
+ org.save(update_fields=["is_active"])
+ self._test_disabled_org_admin_crud(
+ credentials,
+ change_data={"name": "renamed-credentials"},
+ create_data={
+ "name": "new-credentials",
+ "organization": str(org.pk),
+ "connector": credentials.connector,
+ "params": credentials.params,
+ },
+ )
+
+ def test_credentials_disabled_org_admin_org_field_excludes_disabled(self):
+ active_org = self._get_org()
+ disabled_org = self._create_org(name="disabled-org", is_active=False)
+ add_url = reverse(f"admin:{self.app_label}_credentials_add")
+ self._test_disabled_org_admin_org_field_excludes_disabled(
+ add_url, disabled_org, organization=active_org
)
def test_credentials_jsonschema_widget_media(self):
@@ -236,6 +265,24 @@ def test_command_writable_inline_without_permission(self):
response = self.client.get(path)
self.assertNotContains(response, "id_command_set")
+ def test_device_disabled_org_admin_inline_readonly(self):
+ active_device = self.device
+ disabled_org = self._create_org(name="disabled-org", is_active=False)
+ disabled_credentials = self._create_credentials(
+ organization=disabled_org, name="disabled-credentials"
+ )
+ disabled_device_connection = self._create_device_connection(
+ credentials=disabled_credentials
+ )
+ disabled_device = disabled_device_connection.device
+ model_admin = admin.site._registry[Device]
+ self._test_disabled_org_admin_inline_readonly(
+ model_admin,
+ disabled_device,
+ active_obj=active_device,
+ inline_models=(DeviceConnectionInline, CommandWritableInline),
+ )
+
def test_commands_schema_view(self):
url = reverse(
f"admin:{Command._meta.app_label}_{Command._meta.model_name}_schema"
diff --git a/openwisp_controller/connection/tests/test_api.py b/openwisp_controller/connection/tests/test_api.py
index 6e3a827ab..56b43c65d 100644
--- a/openwisp_controller/connection/tests/test_api.py
+++ b/openwisp_controller/connection/tests/test_api.py
@@ -12,7 +12,7 @@
from swapper import load_model
from openwisp_controller.tests.utils import TestAdminMixin
-from openwisp_users.tests.test_api import AuthenticationMixin
+from openwisp_users.tests.test_api import AuthenticationMixin, TestDisabledOrgApiMixin
from .. import settings as app_settings
from ..api.views import CommandListCreateView
@@ -26,7 +26,9 @@
Group = load_model("openwisp_users", "Group")
-class TestCommandsAPI(TestCase, AuthenticationMixin, CreateCommandMixin):
+class TestCommandsAPI(
+ CreateCommandMixin, TestDisabledOrgApiMixin, AuthenticationMixin, TestCase
+):
url_namespace = "connection_api"
def setUp(self):
@@ -183,6 +185,38 @@ def test_command_attributes(self, payload):
self.assertEqual(response.status_code, 201)
test_command_attributes(self, payload)
+ def test_command_create_api_disabled_org(self):
+ self.device_conn.device.organization.is_active = False
+ self.device_conn.device.organization.save(update_fields=["is_active"])
+ url = self._get_path("device_command_list", self.device_id)
+ payload = {"type": "custom", "input": {"command": "echo test"}}
+ response = self.client.post(
+ url,
+ data=json.dumps(payload),
+ content_type="application/json",
+ )
+ self.assertEqual(response.status_code, 403)
+
+ def test_command_disabled_org_api_crud(self):
+ org = self.device_conn.device.organization
+ command = self._create_command(device_conn=self.device_conn)
+ org.is_active = False
+ org.save(update_fields=["is_active"])
+ list_url = self._get_path("device_command_list", self.device_id)
+ detail_url = self._get_path(
+ "device_command_details", self.device_id, command.id
+ )
+ self._test_disabled_org_api_crud(
+ command,
+ detail_url=detail_url,
+ list_url=list_url,
+ create_payload={"type": "custom", "input": {"command": "echo test"}},
+ operations=("list", "retrieve", "create"),
+ organization=org,
+ org_admin_expected={"create": {"status": 403}},
+ superuser_expected={"create": {"status": 403}},
+ )
+
# for ensuring that only related connections are shown
def test_available_connections(self):
device = self._create_device(
@@ -425,12 +459,35 @@ def test_create_command_without_connection(self):
class TestConnectionApi(
- TestAdminMixin, AuthenticationMixin, TestCase, CreateConnectionsMixin
+ TestAdminMixin,
+ CreateConnectionsMixin,
+ TestDisabledOrgApiMixin,
+ AuthenticationMixin,
+ TestCase,
):
def setUp(self):
super().setUp()
self._login()
+ def test_credential_disabled_org_api_crud(self):
+ org = self._get_org()
+ cred = self._create_credentials(organization=org)
+ org.is_active = False
+ org.save(update_fields=["is_active"])
+ self._test_disabled_org_api_crud(
+ cred,
+ detail_url=reverse("connection_api:credential_detail", args=[cred.pk]),
+ list_url=reverse("connection_api:credential_list"),
+ create_payload={
+ "connector": "openwisp_controller.connection.connectors.ssh.Ssh",
+ "name": "new-credentials",
+ "organization": str(org.pk),
+ "auto_add": False,
+ "params": {"username": "root", "password": "password", "port": 22},
+ },
+ update_payload={"name": "renamed-credentials"},
+ )
+
def test_get_credentials_list(self):
self._create_credentials()
path = reverse("connection_api:credential_list")
@@ -501,7 +558,7 @@ def test_put_credential_detail(self):
},
}
expected_queries = (
- 8 if parse_version(REST_FRAMEWORK_VERSION) >= parse_version("3.15") else 7
+ 7 if parse_version(REST_FRAMEWORK_VERSION) >= parse_version("3.15") else 6
)
with self.assertNumQueries(expected_queries):
response = self.client.put(path, data, content_type="application/json")
@@ -519,7 +576,7 @@ def test_patch_credential_detail(self):
cred = self._create_credentials()
path = reverse("connection_api:credential_detail", args=(cred.pk,))
data = {"name": "Change Test credentials"}
- with self.assertNumQueries(7):
+ with self.assertNumQueries(6):
response = self.client.patch(path, data, content_type="application/json")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["name"], "Change Test credentials")
@@ -565,6 +622,52 @@ def test_post_deviceconnection_list(self):
response = self.client.post(path, data, content_type="application/json")
self.assertEqual(response.status_code, 201)
+ def test_post_deviceconnection_list_disabled_org(self):
+ d1 = self._create_device()
+ self._create_config(device=d1)
+ d1.organization.is_active = False
+ d1.organization.save(update_fields=["is_active"])
+ path = reverse("connection_api:deviceconnection_list", args=(d1.pk,))
+ data = {
+ "credentials": self._get_credentials().pk,
+ "update_strategy": app_settings.UPDATE_STRATEGIES[0][0],
+ "enabled": True,
+ "failure_reason": "",
+ }
+ response = self.client.post(path, data, content_type="application/json")
+ self.assertEqual(response.status_code, 403)
+
+ def test_deviceconnection_disabled_org_api_crud(self):
+ dc = self._create_device_connection()
+ org = dc.device.organization
+ org.is_active = False
+ org.save(update_fields=["is_active"])
+ list_url = reverse("connection_api:deviceconnection_list", args=(dc.device.pk,))
+ detail_url = reverse(
+ "connection_api:deviceconnection_detail", args=(dc.device.pk, dc.pk)
+ )
+ blocked_spec = {
+ "create": {"status": 403},
+ "update": {"status": 403, "unchanged": True},
+ }
+ self._test_disabled_org_api_crud(
+ dc,
+ detail_url=detail_url,
+ list_url=list_url,
+ create_payload={
+ "credentials": self._get_credentials().pk,
+ "update_strategy": app_settings.UPDATE_STRATEGIES[0][0],
+ "enabled": True,
+ "failure_reason": "",
+ },
+ update_payload={"enabled": False},
+ unchanged_field="enabled",
+ operations=("list", "retrieve", "create", "update", "delete"),
+ organization=org,
+ org_admin_expected=blocked_spec,
+ superuser_expected=blocked_spec,
+ )
+
def test_post_deviceconenction_with_no_config_device(self):
d1 = self._create_device()
path = reverse("connection_api:deviceconnection_list", args=(d1.pk,))
@@ -631,7 +734,7 @@ def test_delete_deviceconnection_detail(self):
dc = self._create_device_connection()
d1 = dc.device.id
path = reverse("connection_api:deviceconnection_detail", args=(d1, dc.pk))
- with self.assertNumQueries(10):
+ with self.assertNumQueries(11):
response = self.client.delete(path)
self.assertEqual(response.status_code, 204)
diff --git a/openwisp_controller/connection/tests/test_tasks.py b/openwisp_controller/connection/tests/test_tasks.py
index 587c08209..54709ef8a 100644
--- a/openwisp_controller/connection/tests/test_tasks.py
+++ b/openwisp_controller/connection/tests/test_tasks.py
@@ -207,6 +207,27 @@ def test_launch_command_deactivating_device_not_blocked(self, mocked_exec_comman
self.assertNotEqual(command.output, "Device is deactivated.\n")
mocked_exec_command.assert_called_once()
+ @mock.patch(
+ "openwisp_controller.connection.base.models.AbstractCommand._exec_command"
+ )
+ def test_launch_command_disabled_organization(self, mocked_exec_command):
+ dc = self._create_device_connection()
+ command = Command(
+ device=dc.device,
+ connection=dc,
+ type="custom",
+ input={"command": "/usr/sbin/exotic_command"},
+ )
+ command.full_clean()
+ command.save()
+ dc.device.organization.is_active = False
+ dc.device.organization.save(update_fields=["is_active"])
+ tasks.launch_command.delay(command.pk)
+ command.refresh_from_db()
+ self.assertEqual(command.status, "failed")
+ self.assertEqual(command.output, "Organization is disabled.\n")
+ mocked_exec_command.assert_not_called()
+
@mock.patch(
"openwisp_controller.connection.base.models.AbstractCommand._exec_command"
)
diff --git a/openwisp_controller/geo/admin.py b/openwisp_controller/geo/admin.py
index 615a34d66..7eafa9fc4 100644
--- a/openwisp_controller/geo/admin.py
+++ b/openwisp_controller/geo/admin.py
@@ -1,5 +1,6 @@
import reversion
from django.contrib import admin
+from django.contrib.admin.options import ModelAdmin as DjangoModelAdmin
from django.utils.translation import gettext_lazy as _
from django_loci.base.admin import (
AbstractFloorPlanAdmin,
@@ -46,6 +47,16 @@ class FloorPlanAdmin(MultitenantAdminMixin, AbstractFloorPlanAdmin):
form = FloorPlanForm
list_filter = [MultitenantOrgFilter, "created"]
+ def get_form(self, request, obj=None, **kwargs):
+ try:
+ return super().get_form(request, obj, **kwargs)
+ except KeyError as error:
+ if error.args != ("location",):
+ raise
+ form = DjangoModelAdmin.get_form(self, request, obj, **kwargs)
+ form._user = request.user
+ return form
+
FloorPlanAdmin.list_display.insert(1, "organization")
diff --git a/openwisp_controller/geo/api/views.py b/openwisp_controller/geo/api/views.py
index 9650d8230..24cd6fcf3 100644
--- a/openwisp_controller/geo/api/views.py
+++ b/openwisp_controller/geo/api/views.py
@@ -108,7 +108,7 @@ class DeviceCoordinatesView(ProtectedAPIMixin, generics.RetrieveUpdateAPIView):
serializer_class = DeviceCoordinatesSerializer
permission_classes = (DevicePermission,)
queryset = Device.objects.select_related(
- "devicelocation", "devicelocation__location"
+ "organization", "devicelocation", "devicelocation__location"
)
def get_queryset(self):
@@ -124,7 +124,9 @@ def get_location(self, device):
def get_object(self, *args, **kwargs):
device = super().get_object()
- if self.request.method not in ("GET", "HEAD") and device.is_deactivated():
+ if self.request.method not in ("GET", "HEAD") and (
+ device.is_deactivated() or not device.organization.is_active
+ ):
raise PermissionDenied
location = self.get_location(device)
if location:
@@ -164,6 +166,7 @@ class DeviceLocationView(
organization_field = "content_object__organization"
organization_lookup = "organization__in"
_device_field = "content_object"
+ select_related_organization = False
def get_queryset(self):
qs = super().get_queryset()
@@ -173,7 +176,9 @@ def get_queryset(self):
return qs.none()
def get_parent_queryset(self):
- return Device.objects.filter(pk=self.kwargs["pk"])
+ return Device.objects.filter(pk=self.kwargs["pk"]).select_related(
+ "organization"
+ )
def get_serializer_context(self):
context = super().get_serializer_context()
@@ -205,8 +210,9 @@ def get_object_or_none(self):
if self.request.method == "PUT":
# For PUT-as-create operation, we need to ensure that we have
# relevant permissions, as if this was a POST request. This
- # will either raise a PermissionDenied exception, or simply
- # return None.
+ # will either raise a PermissionDenied exception (covers a
+ # disabled organization too, since RelatedDeviceModelPermission
+ # checks device.organization.is_active) or simply return None.
self.check_permissions(clone_request(self.request, "POST"))
else:
# PATCH requests where the object does not exist should still
@@ -287,7 +293,9 @@ def get_parent_queryset(self):
def get_queryset(self):
super().get_queryset()
- qs = Device.objects.filter(devicelocation__location_id=self.kwargs["pk"])
+ qs = Device.objects.filter(
+ devicelocation__location_id=self.kwargs["pk"]
+ ).select_related("organization")
return qs
def get_has_floorplan(self, qs):
@@ -308,6 +316,7 @@ class FloorPlanListCreateView(ProtectedAPIMixin, generics.ListCreateAPIView):
pagination_class = OpenWispPagination
filter_backends = [filters.DjangoFilterBackend]
filterset_class = FloorPlanOrganizationFilter
+ select_related_organization = False
class FloorPlanDetailView(
@@ -316,6 +325,7 @@ class FloorPlanDetailView(
):
serializer_class = FloorPlanSerializer
queryset = FloorPlan.objects.select_related()
+ select_related_organization = False
class LocationListCreateView(ProtectedAPIMixin, generics.ListCreateAPIView):
diff --git a/openwisp_controller/geo/estimated_location/tasks.py b/openwisp_controller/geo/estimated_location/tasks.py
index 1ca233024..b5262ad43 100644
--- a/openwisp_controller/geo/estimated_location/tasks.py
+++ b/openwisp_controller/geo/estimated_location/tasks.py
@@ -26,10 +26,14 @@ def manage_estimated_locations(device_pk, ip_address):
# (PostgreSQL cannot lock nullable joined rows).
device = (
Device.objects.select_for_update(of=("self",))
- .select_related("devicelocation__location")
+ .select_related("organization", "devicelocation__location")
.get(pk=device_pk)
)
- if device.is_deactivated() or normalize_ip(device.last_ip) != ip_address:
+ if (
+ device.is_deactivated()
+ or not device.organization.is_active
+ or normalize_ip(device.last_ip) != ip_address
+ ):
logger.info(
f"Device {device_pk} no longer needs estimated location "
f"for {ip_address}"
diff --git a/openwisp_controller/geo/estimated_location/tests/tests.py b/openwisp_controller/geo/estimated_location/tests/tests.py
index b3f0d3772..b2de2902e 100644
--- a/openwisp_controller/geo/estimated_location/tests/tests.py
+++ b/openwisp_controller/geo/estimated_location/tests/tests.py
@@ -1026,6 +1026,19 @@ def test_manage_locations_skips_when_deactivated(self, mock_info):
f"for {device.last_ip}"
)
+ @mock.patch.object(config_app_settings, "WHOIS_CONFIGURED", True)
+ @mock.patch(_ESTIMATED_LOCATION_INFO_LOGGER)
+ def test_manage_locations_skips_when_org_disabled(self, mock_info):
+ whois_obj = self._create_whois_info(ip_address="172.217.22.14")
+ device = self._create_device(last_ip=whois_obj.ip_address)
+ device.organization.is_active = False
+ device.organization.save(update_fields=["is_active"])
+ manage_estimated_locations(device.pk, device.last_ip)
+ mock_info.assert_called_once_with(
+ f"Device {device.pk} no longer needs estimated location "
+ f"for {device.last_ip}"
+ )
+
@mock.patch.object(config_app_settings, "WHOIS_CONFIGURED", True)
@mock.patch(
"openwisp_controller.geo.estimated_location.service.current_app.send_task",
diff --git a/openwisp_controller/geo/tests/test_admin.py b/openwisp_controller/geo/tests/test_admin.py
index 8fd113cad..61b8af897 100644
--- a/openwisp_controller/geo/tests/test_admin.py
+++ b/openwisp_controller/geo/tests/test_admin.py
@@ -1,8 +1,9 @@
from unittest import mock
+from django.contrib import admin
from django.contrib.auth import get_user_model
from django.core.files.base import ContentFile
-from django.test import TestCase
+from django.test import RequestFactory, TestCase
from django.urls import reverse
from django_loci.tests.base.test_admin import BaseTestAdmin
from swapper import load_model
@@ -12,6 +13,7 @@
from ...config import settings as config_app_settings
from ...config.tests.test_admin import TestImportExportMixin
from ...tests.utils import TestAdminMixin
+from ..admin import DeviceLocationInline, FloorPlanAdmin
from .utils import TestGeoMixin
Device = load_model("config", "Device")
@@ -129,6 +131,60 @@ def test_floorplan_queryset(self):
],
)
+ def test_location_disabled_org_admin_crud(self):
+ org = self._create_organization(name="loc-disabled-org")
+ location = self._create_location(
+ name="loc-disabled", type="indoor", organization=org
+ )
+ org.is_active = False
+ org.save()
+ self._test_disabled_org_admin_crud(
+ location,
+ change_data={"name": "renamed-location"},
+ create_data={
+ "name": "new-location",
+ "organization": str(org.pk),
+ "type": "indoor",
+ },
+ )
+
+ def test_floorplan_disabled_org_admin_crud(self):
+ org = self._create_organization(name="fl-disabled-org")
+ location = self._create_location(
+ name="fl-disabled-location", type="indoor", organization=org
+ )
+ floorplan = self._create_floorplan(location=location, organization=org)
+ org.is_active = False
+ org.save()
+ self._test_disabled_org_admin_crud(
+ floorplan,
+ change_data={"floor": 2},
+ unchanged_field="floor",
+ create_data={"floor": 3, "location": str(location.pk)},
+ )
+
+ def test_location_disabled_org_admin_org_field_excludes_disabled(self):
+ self._create_admin()
+ active_org = self._get_org()
+ disabled_org = self._create_organization(
+ name="loc-disabled-org", is_active=False
+ )
+ add_url = reverse(f"admin:{self.app_label}_location_add")
+ self._test_disabled_org_admin_org_field_excludes_disabled(
+ add_url, disabled_org, organization=active_org
+ )
+
+ def test_floorplan_disabled_org_admin_org_field_excludes_disabled(self):
+ self._create_admin()
+ active_org = self._get_org()
+ disabled_org = self._create_organization(
+ name="fl-disabled-org", is_active=False
+ )
+ add_url = reverse(f"admin:{self.app_label}_floorplan_add")
+ self._test_disabled_org_admin_org_field_excludes_disabled(
+ add_url, disabled_org, organization=active_org
+ )
+
def test_admin_menu_groups(self):
# Test menu group (openwisp-utils menu group) for Location , FloorPlan
@@ -156,6 +212,30 @@ def test_location_readonly_fields(self):
response = self.client.get(url)
self.assertNotContains(response, 'Cas & Certificates ',
html=True,
)
+
+ def test_ca_disabled_org_admin_crud(self):
+ org = self._create_org(name="disabled-org", slug="disabled-org")
+ ca = self._create_ca(name="disabled-ca", organization=org)
+ org.is_active = False
+ org.save(update_fields=["is_active"])
+ self._test_disabled_org_admin_crud(
+ ca,
+ change_data={"name": "renamed-ca"},
+ create_data={"name": "new-ca", "organization": str(org.pk)},
+ )
+
+ def test_ca_disabled_org_admin_org_field_excludes_disabled(self):
+ active_org = self._get_org()
+ disabled_org = self._create_org(name="disabled-org", is_active=False)
+ add_url = reverse(f"admin:{self.app_label}_ca_add")
+ self._test_disabled_org_admin_org_field_excludes_disabled(
+ add_url, disabled_org, organization=active_org
+ )
+
+ def test_cert_disabled_org_admin_crud(self):
+ org = self._create_org(name="disabled-org", slug="disabled-org")
+ ca = self._create_ca(name="disabled-ca", organization=org)
+ cert = self._create_cert(name="disabled-cert", ca=ca, organization=org)
+ org.is_active = False
+ org.save(update_fields=["is_active"])
+ self._test_disabled_org_admin_crud(
+ cert,
+ change_data={"name": "renamed-cert"},
+ create_data={
+ "name": "new-cert",
+ "organization": str(org.pk),
+ "ca": str(ca.pk),
+ },
+ )
+
+ def test_cert_disabled_org_admin_org_field_excludes_disabled(self):
+ active_org = self._get_org()
+ disabled_org = self._create_org(name="disabled-org", is_active=False)
+ add_url = reverse(f"admin:{self.app_label}_cert_add")
+ self._test_disabled_org_admin_org_field_excludes_disabled(
+ add_url, disabled_org, organization=active_org
+ )
+
+ def test_ca_renew_action_skips_disabled_org(self):
+ self.client.force_login(self._get_admin())
+ org = self._get_org()
+ ca = self._create_ca(name="ca-disabled", organization=org)
+ cert = self._create_cert(name="cert-disabled", ca=ca, organization=org)
+ old_serial = ca.serial_number
+ old_cert_serial = cert.serial_number
+ org.is_active = False
+ org.save(update_fields=["is_active"])
+ path = reverse(f"admin:{self.app_label}_ca_changelist")
+ payload = {
+ "action": "renew_ca",
+ "_selected_action": [ca.pk],
+ "post": "yes",
+ }
+ response = self.client.post(path, payload, follow=True)
+ ca.refresh_from_db()
+ cert.refresh_from_db()
+ self.assertEqual(str(ca.serial_number), str(old_serial))
+ # renew_ca cascades to child certs when it runs: a skipped CA
+ # renewal must also leave its certs untouched
+ self.assertEqual(str(cert.serial_number), str(old_cert_serial))
+ self.assertContains(
+ response, "Actions cannot modify objects of disabled organizations."
+ )
+
+ def test_cert_revoke_action_allowed_for_disabled_org(self):
+ self.client.force_login(self._get_admin())
+ org = self._get_org()
+ ca = self._create_ca(name="ca-disabled", organization=org)
+ cert = self._create_cert(name="cert-disabled", ca=ca, organization=org)
+ org.is_active = False
+ org.save(update_fields=["is_active"])
+ changelist = reverse(f"admin:{self.app_label}_cert_changelist")
+ revoke_payload = {"action": "revoke_action", "_selected_action": [cert.pk]}
+ self.client.post(changelist, revoke_payload, follow=True)
+ cert.refresh_from_db()
+ self.assertEqual(cert.revoked, True)
+ self.assertIn(cert, list(ca.get_revoked_certs()))
+
+ def test_cert_renew_action_skips_disabled_org(self):
+ self.client.force_login(self._get_admin())
+ org = self._get_org()
+ ca = self._create_ca(name="ca-disabled", organization=org)
+ cert = self._create_cert(name="cert-disabled", ca=ca, organization=org)
+ org.is_active = False
+ org.save(update_fields=["is_active"])
+ changelist = reverse(f"admin:{self.app_label}_cert_changelist")
+ old_serial = cert.serial_number
+ renew_payload = {
+ "action": "renew_cert",
+ "_selected_action": [cert.pk],
+ "post": "yes",
+ }
+ response = self.client.post(changelist, renew_payload, follow=True)
+ cert.refresh_from_db()
+ self.assertEqual(str(cert.serial_number), str(old_serial))
+ self.assertContains(
+ response, "Actions cannot modify objects of disabled organizations."
+ )
diff --git a/openwisp_controller/pki/tests/test_api.py b/openwisp_controller/pki/tests/test_api.py
index 51faebdaf..99d638af7 100644
--- a/openwisp_controller/pki/tests/test_api.py
+++ b/openwisp_controller/pki/tests/test_api.py
@@ -5,7 +5,7 @@
from swapper import load_model
from openwisp_controller.tests.utils import TestAdminMixin
-from openwisp_users.tests.test_api import AuthenticationMixin
+from openwisp_users.tests.test_api import AuthenticationMixin, TestDisabledOrgApiMixin
from openwisp_users.tests.utils import TestOrganizationMixin
from openwisp_utils.tests import AssertNumQueriesSubTestMixin, capture_any_output
@@ -19,7 +19,7 @@ class TestPkiApi(
AssertNumQueriesSubTestMixin,
TestAdminMixin,
TestPkiMixin,
- TestOrganizationMixin,
+ TestDisabledOrgApiMixin,
AuthenticationMixin,
TestCase,
):
@@ -137,7 +137,7 @@ def test_ca_patch_api(self):
data = {
"name": "change-ca1",
}
- with self.assertNumQueries(6):
+ with self.assertNumQueries(5):
r = self.client.patch(path, data, content_type="application/json")
self.assertEqual(r.status_code, 200)
self.assertEqual(r.data["name"], "change-ca1")
@@ -168,6 +168,42 @@ def test_ca_post_renew_api(self):
self.assertNotEqual(ca1.serial_number, old_serial_num)
self.assertNotEqual(r.data["serial_number"], old_serial_num)
+ def test_ca_post_renew_api_disabled_org(self):
+ org = self._get_org()
+ ca1 = self._create_ca(name="ca1", organization=org)
+ old_serial_num = ca1.serial_number
+ org.is_active = False
+ org.save(update_fields=["is_active"])
+ path = reverse("pki_api:ca_renew", args=[ca1.pk])
+ r = self.client.post(path)
+ ca1.refresh_from_db()
+ self.assertEqual(r.status_code, 403)
+ self.assertEqual(str(ca1.serial_number), str(old_serial_num))
+
+ def test_ca_disabled_org_api_crud(self):
+ org = self._create_org(name="disabled-org", slug="disabled-org")
+ ca = self._create_ca(name="disabled-ca", organization=org)
+ org.is_active = False
+ org.save(update_fields=["is_active"])
+ create_payload = self._ca_data
+ create_payload["organization"] = str(org.pk)
+ self._test_disabled_org_api_crud(
+ ca,
+ detail_url=reverse("pki_api:ca_detail", args=[ca.pk]),
+ list_url=reverse("pki_api:ca_list"),
+ create_payload=create_payload,
+ update_payload={"name": "renamed-ca"},
+ )
+
+ def test_crl_download_disabled_org(self):
+ org = self._create_org(name="disabled-org", slug="disabled-org")
+ ca = self._create_ca(name="disabled-ca", organization=org)
+ org.is_active = False
+ org.save(update_fields=["is_active"])
+ path = reverse("pki_api:crl_download", args=[ca.pk])
+ r = self.client.get(path)
+ self.assertEqual(r.status_code, 200)
+
def test_cert_post_api(self):
path = reverse("pki_api:cert_list")
data = self._cert_data
@@ -211,6 +247,51 @@ def test_cert_post_with_extensions_field(self):
self.assertEqual(Cert.objects.count(), 1)
self.assertEqual(r.data["extensions"], [])
+ def test_cert_revoke_api_allowed_for_disabled_org(self):
+ org = self._get_org()
+ ca = self._create_ca(organization=org)
+ cert = self._create_cert(ca=ca, organization=org)
+ org.is_active = False
+ org.save(update_fields=["is_active"])
+ revoke_path = reverse("pki_api:cert_revoke", args=[cert.pk])
+ revoke_response = self.client.post(revoke_path)
+ self.assertEqual(revoke_response.status_code, 200)
+ cert.refresh_from_db()
+ self.assertEqual(cert.revoked, True)
+ self.assertIn(cert, list(ca.get_revoked_certs()))
+
+ def test_cert_renew_api_disabled_org(self):
+ org = self._get_org()
+ ca = self._create_ca(organization=org)
+ cert = self._create_cert(ca=ca, organization=org)
+ serial_number = str(cert.serial_number)
+ org.is_active = False
+ org.save(update_fields=["is_active"])
+ renew_path = reverse("pki_api:cert_renew", args=[cert.pk])
+ renew_response = self.client.post(renew_path)
+ self.assertEqual(renew_response.status_code, 403)
+ cert.refresh_from_db()
+ self.assertEqual(cert.revoked, False)
+ self.assertEqual(cert.serial_number, serial_number)
+ self.assertEqual(Cert.objects.count(), 1)
+
+ def test_cert_disabled_org_api_crud(self):
+ org = self._create_org(name="disabled-org", slug="disabled-org")
+ ca = self._create_ca(name="disabled-ca", organization=org)
+ cert = self._create_cert(name="disabled-cert", ca=ca, organization=org)
+ org.is_active = False
+ org.save(update_fields=["is_active"])
+ create_payload = self._cert_data
+ create_payload["organization"] = str(org.pk)
+ create_payload["ca"] = ca.pk
+ self._test_disabled_org_api_crud(
+ cert,
+ detail_url=reverse("pki_api:cert_detail", args=[cert.pk]),
+ list_url=reverse("pki_api:cert_list"),
+ create_payload=create_payload,
+ update_payload={"name": "renamed-cert"},
+ )
+
def test_cert_post_with_date_none(self):
path = reverse("pki_api:cert_list")
data = {
diff --git a/openwisp_controller/subnet_division/tasks.py b/openwisp_controller/subnet_division/tasks.py
index 998e9a1c2..e57f3f417 100644
--- a/openwisp_controller/subnet_division/tasks.py
+++ b/openwisp_controller/subnet_division/tasks.py
@@ -88,14 +88,21 @@ def _create_ipaddress_and_subnetdivision_index_objects(ips, indexes):
generated_indexes = []
try:
- division_rule = SubnetDivisionRule.objects.get(id=rule_id)
+ division_rule = SubnetDivisionRule.objects.select_related("organization").get(
+ id=rule_id
+ )
except SubnetDivisionRule.DoesNotExist as e:
logger.warning(
"Failed to provision extra IPs for Subnet Division Rule "
f'with id: "{rule_id}", reason: {e}'
)
return
-
+ if division_rule.organization_id and not division_rule.organization.is_active:
+ logger.info(
+ "Skipping extra IP provisioning for rule %s of disabled organization",
+ rule_id,
+ )
+ return
index_queryset = division_rule.subnetdivisionindex_set.filter(
subnet_id__isnull=False,
config_id__isnull=False,
@@ -134,12 +141,17 @@ def _create_ipaddress_and_subnetdivision_index_objects(ips, indexes):
@shared_task
def provision_subnet_ip_for_existing_devices(rule_id):
try:
- rule = SubnetDivisionRule.objects.get(id=rule_id)
+ rule = SubnetDivisionRule.objects.select_related("organization").get(id=rule_id)
except SubnetDivisionRule.DoesNotExist as error:
logger.warning(
"Failed to provision IPs on existing devices for Subnet "
f'Division Rule with id: "{rule_id}", reason: {error}'
)
return
- else:
- rule.rule_class.provision_for_existing_objects(rule)
+ if rule.organization_id and not rule.organization.is_active:
+ logger.info(
+ "Skipping subnet provisioning for rule %s of disabled organization",
+ rule_id,
+ )
+ return
+ rule.rule_class.provision_for_existing_objects(rule)
diff --git a/openwisp_controller/subnet_division/tests/test_models.py b/openwisp_controller/subnet_division/tests/test_models.py
index 071fb576f..58a1acee2 100644
--- a/openwisp_controller/subnet_division/tests/test_models.py
+++ b/openwisp_controller/subnet_division/tests/test_models.py
@@ -680,6 +680,61 @@ def test_sharable_vpn_vpnclient_subnet_multiple_rules(self):
self.assertNotIn(config1_subnets.first(), config2_subnets)
self.assertNotIn(config1_subnets.last(), config2_subnets)
+ def test_provision_subnet_ip_skips_disabled_org(self):
+ org = self._create_org(name="disabled-org", slug="disabled-org")
+ rule = self._get_vpn_subdivision_rule(
+ organization=org,
+ master_subnet=self._get_master_subnet(
+ subnet="10.200.0.0/16", organization=org
+ ),
+ )
+ org.is_active = False
+ org.save(update_fields=["is_active"])
+ with (
+ patch(
+ "openwisp_controller.subnet_division.tasks.logger.info"
+ ) as mocked_logger,
+ patch.object(rule.rule_class, "provision_for_existing_objects") as mocked,
+ ):
+ tasks.provision_subnet_ip_for_existing_devices.run(rule.id)
+ mocked_logger.assert_called_once_with(
+ "Skipping subnet provisioning for rule %s of disabled organization",
+ rule.id,
+ )
+ mocked.assert_not_called()
+
+ def test_provision_extra_ips_skips_disabled_org(self):
+ org = self._create_org(name="disabled-org", slug="disabled-org")
+ master_subnet = self._get_master_subnet(
+ subnet="10.201.0.0/16", organization=org
+ )
+ rule = self._get_vpn_subdivision_rule(
+ organization=org, master_subnet=master_subnet
+ )
+ config = self._create_config(organization=org)
+ subnet = self._create_subnet(
+ subnet="10.201.0.0/28", organization=org, master_subnet=master_subnet
+ )
+ SubnetDivisionIndex.objects.create(
+ keyword="OW_subnet0",
+ subnet=subnet,
+ rule=rule,
+ config=config,
+ )
+ old_number_of_ips = rule.number_of_ips
+ ip_count_before = IpAddress.objects.count()
+ index_count_before = SubnetDivisionIndex.objects.count()
+ org.is_active = False
+ org.save(update_fields=["is_active"])
+ with patch("openwisp_controller.subnet_division.tasks.logger.info") as mocked:
+ tasks.provision_extra_ips.run(rule.id, old_number_of_ips=old_number_of_ips)
+ mocked.assert_called_once_with(
+ "Skipping extra IP provisioning for rule %s of disabled organization",
+ rule.id,
+ )
+ self.assertEqual(IpAddress.objects.count(), ip_count_before)
+ self.assertEqual(SubnetDivisionIndex.objects.count(), index_count_before)
+
def test_device_deleted(self):
rule = self._get_vpn_subdivision_rule()
subnet_query = self.subnet_query.filter(organization_id=self.org.id).exclude(
diff --git a/openwisp_controller/tests/test_users_integration.py b/openwisp_controller/tests/test_users_integration.py
index 4481ab32d..a58931823 100644
--- a/openwisp_controller/tests/test_users_integration.py
+++ b/openwisp_controller/tests/test_users_integration.py
@@ -1,3 +1,4 @@
+from openwisp_controller.config.admin import OrganizationLimitsInline
from openwisp_users.tests.test_admin import TestUsersAdmin
from .mixins import GetEditFormInlineMixin
@@ -10,5 +11,14 @@ class TestUsersIntegration(GetEditFormInlineMixin, TestUsersAdmin):
is_integration_test = True
+ def _get_disabled_org_test_excluded_inline(self):
+ inlines = super()._get_disabled_org_test_excluded_inline()
+ # The shared disabled-org test helper asserts has_delete_permission
+ # stays True for every inline on a disabled organization, but this
+ # inline's has_delete_permission always returns False regardless of
+ # organization status, so it must be excluded from that assertion.
+ inlines += [OrganizationLimitsInline]
+ return inlines
+
del TestUsersAdmin
diff --git a/tests/openwisp2/sample_users/migrations/0006_user_password_based_token.py b/tests/openwisp2/sample_users/migrations/0006_user_password_based_token.py
index f689d67d2..c48d7f94b 100644
--- a/tests/openwisp2/sample_users/migrations/0006_user_password_based_token.py
+++ b/tests/openwisp2/sample_users/migrations/0006_user_password_based_token.py
@@ -1,6 +1,7 @@
-# Generated by Django 5.2.13 on 2026-08-14 20:35
+# Generated by Django 5.2.16 on 2026-07-29 19:17
from django.db import migrations, models
+from django.utils.translation import gettext_lazy as _
class Migration(migrations.Migration):
@@ -16,15 +17,16 @@ class Migration(migrations.Migration):
field=models.BooleanField(
blank=True,
default=None,
- help_text=(
- "Indicates whether the last authentication token was obtained "
- "using the local password. When false, the token came from an "
- "external method (eg: SSO, SAML) and password expiration is not "
- "enforced for it. None means no token has been issued for this "
- "user since this feature was introduced."
+ help_text=_(
+ "Indicates whether the last authentication token was"
+ " obtained using the local password. When false, the"
+ " token came from an external method (eg: SSO, SAML)"
+ " and password expiration is not enforced for it. None"
+ " means no token has been issued for this user since"
+ " this feature was introduced."
),
null=True,
- verbose_name="password based token",
+ verbose_name=_("password based token"),
),
),
]
diff --git a/tests/openwisp2/sample_users/tests.py b/tests/openwisp2/sample_users/tests.py
index cfee27509..554eb8e75 100644
--- a/tests/openwisp2/sample_users/tests.py
+++ b/tests/openwisp2/sample_users/tests.py
@@ -1,13 +1,15 @@
from unittest.mock import patch
from openwisp_controller.tests.mixins import GetEditFormInlineMixin
+from openwisp_controller.tests.test_users_integration import (
+ TestUsersIntegration as BaseTestUsersAdmin,
+)
from openwisp_users.tests.test_admin import (
TestBasicUsersIntegration as BaseTestBasicUsersIntegration,
)
from openwisp_users.tests.test_admin import (
TestMultitenantAdmin as BaseTestMultitenantAdmin,
)
-from openwisp_users.tests.test_admin import TestUsersAdmin as BaseTestUsersAdmin
from openwisp_users.tests.test_models import TestUsers as BaseTestUsers
additional_fields = [
@@ -15,9 +17,8 @@
]
-class TestUsersAdmin(GetEditFormInlineMixin, BaseTestUsersAdmin):
+class TestUsersAdmin(BaseTestUsersAdmin):
app_label = "sample_users"
- is_integration_test = True
_additional_user_fields = additional_fields