Skip to content

Commit 977b7b6

Browse files
authored
[change] Enforce deactivated device state across modules #1338
This change introduces a generic enforcement of the deactivated device state across OpenWISP Controller. When a device is deactivated, it is treated as non-operational. This means no new configuration, management, or connectivity operations will target it. However, controller state, security, and cleanup processes will continue to run to maintain system consistency. Specifically, device registration and re-registration are blocked for deactivated devices. Any workflow that would implicitly restore configuration state or make a deactivated device appear operational again is also blocked. Automatic template assignment and propagation from groups will not run for deactivated configurations. Active management traffic such as remote command execution is blocked. Configuration pushes and reachability checks are allowed only during the transitional state of deactivation. Once a configuration is fully deactivated, no further controller-initiated communication will occur. State-only and cleanup operations, like certificate revocation and cache invalidation, remain allowed. Closes #1338
1 parent 55c3777 commit 977b7b6

20 files changed

Lines changed: 470 additions & 34 deletions

File tree

docs/user/device-config-status.rst

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@ scheduled to be removed from the device.
3535

3636
The device has been deactivated. The configuration applied through
3737
OpenWISP has been removed, and any other operation to manage the device
38-
will be prevented or rejected.
38+
will be prevented or rejected. This also includes network operations which
39+
would normally push configuration updates, for example changes to shared
40+
templates.
3941

4042
.. note::
4143

openwisp_controller/config/admin.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -679,6 +679,7 @@ def change_group(self, request, queryset):
679679
form = ChangeDeviceGroupForm(data=request.POST, org_id=org_id)
680680
if form.is_valid():
681681
group = form.cleaned_data["device_group"]
682+
queryset = queryset.filter(_is_deactivated=False)
682683
# Evaluate queryset to store old group id
683684
old_group_qs = list(queryset)
684685
queryset.update(group=group or None)

openwisp_controller/config/base/config.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -848,13 +848,15 @@ def activate(self):
848848
self._invalidate_backend_instance_cache()
849849
old_checksum = self.checksum
850850
self.add_default_templates()
851-
if self.device._get_group():
852-
self.device.manage_devices_group_templates(
853-
device_ids=self.device.id,
854-
old_group_ids=None,
855-
group_id=self.device.group_id,
851+
if self.device._has_group():
852+
# Call manage_group_templates directly rather than going through
853+
# manage_devices_group_templates, which would skip the device because
854+
# it is still marked as deactivated at this point in the activation flow.
855+
self.manage_group_templates(
856+
self.device.group.templates.all(),
857+
self.get_template_model().objects.none(),
856858
)
857-
del self.backend_instance
859+
self._invalidate_backend_instance_cache()
858860
if old_checksum == self.checksum:
859861
# Accelerate activation if the configuration remains
860862
# unchanged (i.e. empty configuration)
@@ -987,6 +989,11 @@ def manage_backend_changed(cls, instance_id, old_backend, backend, **kwargs):
987989
Config = load_model("config", "Config")
988990
Template = load_model("config", "Template")
989991
config = Config.objects.get(pk=instance_id)
992+
# All modification operations are blocked on deactivated devices.
993+
# Thus, a user cannot edit the backend for device when it is deactivating.
994+
# Therefore, it will be safe to block this operation here.
995+
if config.is_deactivating_or_deactivated():
996+
return
990997
device_group = config.device.group
991998
if not device_group:
992999
return

openwisp_controller/config/base/device.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -183,8 +183,15 @@ def _get_organization__config_settings(self):
183183
)
184184

185185
def is_deactivated(self):
186+
"""Return whether deactivation has been initiated."""
186187
return self._is_deactivated
187188

189+
def is_fully_deactivated(self):
190+
"""Return whether the device and its configuration are fully deactivated."""
191+
return self.is_deactivated() and (
192+
not self._has_config() or self.config.is_deactivated()
193+
)
194+
188195
def deactivate(self):
189196
if self.is_deactivated():
190197
# The device has already been deactivated.
@@ -299,11 +306,10 @@ def save(self, *args, **kwargs):
299306
self._check_changed_fields()
300307

301308
def delete(self, using=None, keep_parents=False, check_deactivated=True):
302-
if check_deactivated and (
303-
not self.is_deactivated()
304-
or (self._has_config() and not self.config.is_deactivated())
305-
):
306-
raise PermissionDenied("The device must be deactivated prior to deletion")
309+
if check_deactivated and (not self.is_fully_deactivated()):
310+
raise PermissionDenied(
311+
_("The device must be deactivated prior to deletion")
312+
)
307313
return super().delete(using, keep_parents)
308314

309315
def _check_changed_fields(self):
@@ -479,6 +485,10 @@ def create_default_config(self, **options):
479485
creates a new config instance to apply group templates
480486
if group has templates.
481487
"""
488+
if self.is_deactivated():
489+
# All modification operations are blocked on deactivated devices.
490+
# Hence, default config should not be created for deactivated devices.
491+
return
482492
if not (self.group and self.group.templates.exists()):
483493
return
484494
config = self.get_temp_config_instance(
@@ -499,6 +509,11 @@ def manage_devices_group_templates(cls, device_ids, old_group_ids, group_id):
499509
old_group_ids = [old_group_ids]
500510
for device_id, old_group_id in zip(device_ids, old_group_ids):
501511
device = Device.objects.get(pk=device_id)
512+
if device.is_deactivated():
513+
# Skip deactivated devices: their configuration is intentionally
514+
# emptied during deactivation, so re-applying group templates
515+
# would break that state and trigger a push to the device.
516+
continue
502517
if not hasattr(device, "config"):
503518
device.create_default_config()
504519
config_created = hasattr(device, "config")

openwisp_controller/config/base/device_group.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ def manage_group_templates(cls, group_id, old_template_ids, template_ids):
110110
device_group = DeviceGroup.objects.get(id=group_id)
111111
templates = Template.objects.filter(pk__in=template_ids)
112112
old_templates = Template.objects.filter(pk__in=old_template_ids)
113-
for device in device_group.device_set.iterator():
113+
for device in device_group.device_set.exclude(_is_deactivated=True).iterator():
114114
if not hasattr(device, "config"):
115115
device.create_default_config()
116116
device.config.manage_group_templates(templates, old_templates)

openwisp_controller/config/controller/views.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,12 @@ def post(self, request, *args, **kwargs):
230230
bad_request = forbid_unallowed(request, "POST", "key", device.key)
231231
if bad_request:
232232
return bad_request
233+
if device.is_deactivated():
234+
# Inventory metadata must not be recorded for deactivated devices.
235+
# GetDeviceView already returns 404 once the config is fully
236+
# "deactivated"; this additionally rejects the transient
237+
# "deactivating" state, where the device flag is already set.
238+
return ControllerResponse("error: device deactivated", status=403)
233239
# update device information
234240
for attr in self.UPDATABLE_FIELDS:
235241
if attr in request.POST:
@@ -408,7 +414,9 @@ def post(self, request, *args, **kwargs):
408414
# (key is not None only if CONSISTENT_REGISTRATION is enabled)
409415
new = False
410416
try:
411-
device = self.model.objects.get(key=key)
417+
device = self.model.objects.select_related("config").get(key=key)
418+
if device.is_deactivated():
419+
return ControllerResponse("error: device deactivated", status=403)
412420
# update device info
413421
for attr in self.UPDATABLE_FIELDS:
414422
if attr in request.POST:

openwisp_controller/config/tests/test_admin.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import csv
12
import io
23
import json
34
import os
@@ -653,6 +654,68 @@ def test_change_device_group_action_perms(self):
653654
extra_payload={"device_group": group.pk, "apply": True},
654655
)
655656

657+
def test_device_export_includes_deactivated_config_status(self):
658+
device = self._create_device_config()
659+
device.deactivate()
660+
response = self.client.post(
661+
reverse(f"admin:{self.app_label}_device_export"), {"format": "0"}
662+
)
663+
self.assertNotContains(response, "error")
664+
rows = list(csv.reader(response.content.decode().splitlines()))
665+
header = rows[0]
666+
status_col = header.index("config_status")
667+
self.assertEqual(rows[1][status_col], "deactivated")
668+
669+
def test_device_import_deactivated_config_status_ignored(self):
670+
# config_status is readonly=True in DeviceResource, so importing a
671+
# row with config_status=deactivated must NOT create a deactivated device.
672+
org = self._get_org(org_name="default")
673+
contents = (
674+
"name,mac_address,organization,group,model,os,system,notes,last_ip,"
675+
"management_ip,config_status,config_backend,config_data,config_context,"
676+
"config_templates,created,modified,id,key,organization_id,group_id\n"
677+
"test-deactivated,00:11:22:33:44:66,{org_name},,,,,,,,"
678+
"deactivated,netjsonconfig.OpenWrt,,,,"
679+
"2022-10-17 15:26:51,2022-10-17 15:26:51,"
680+
"559871c5-ce3d-4c7e-9176-fb6623d562f3,934d0799b1ce3a454bbb585cda1d7a49,"
681+
"{org_id},"
682+
).strip()
683+
contents = contents.format(org_name=org.name, org_id=org.id)
684+
csv_file = ContentFile(contents)
685+
response = self.client.post(
686+
reverse(f"admin:{self.app_label}_device_import"),
687+
{"format": "0", "import_file": csv_file, "file_name": "test.csv"},
688+
)
689+
self.assertFalse(response.context["result"].has_errors())
690+
confirm_form = response.context["confirm_form"]
691+
data = confirm_form.initial
692+
response = self.client.post(
693+
reverse(f"admin:{self.app_label}_device_process_import"), data, follow=True
694+
)
695+
self.assertEqual(response.status_code, 200)
696+
device = Device.objects.get(name="test-deactivated")
697+
self.assertFalse(device._is_deactivated)
698+
self.assertTrue(device._has_config())
699+
self.assertNotEqual(device.config.status, "deactivated")
700+
701+
def test_change_group_action_skips_deactivated_device(self):
702+
path = reverse(f"admin:{self.app_label}_device_changelist")
703+
org = self._get_org(org_name="default")
704+
group = self._create_device_group(name="test-group", organization=org)
705+
device = self._create_device(organization=org)
706+
device.deactivate()
707+
post_data = {
708+
"_selected_action": [device.pk],
709+
"action": "change_group",
710+
"csrfmiddlewaretoken": "test",
711+
"apply": True,
712+
"device_group": group.pk,
713+
}
714+
response = self.client.post(path, post_data, follow=True)
715+
self.assertEqual(response.status_code, 200)
716+
device.refresh_from_db()
717+
self.assertIsNone(device.group)
718+
656719
def test_device_import_with_group_apply_templates(self):
657720
org = self._get_org(org_name="default")
658721
template = self._create_template(name="template")
@@ -3038,6 +3101,26 @@ def test_change_device_group_action_changes_templates(self):
30383101
self.assertEqual(response.status_code, 200)
30393102
self.assertEqual(len(mocked.call_args_list), 2)
30403103

3104+
with self.subTest("Change group skips deactivated devices in signals"):
3105+
device3 = self._create_device(
3106+
organization=org1,
3107+
group=dg1,
3108+
name="default.test.device3",
3109+
mac_address="AA:BB:CC:DD:EE:01",
3110+
)
3111+
device3.deactivate()
3112+
data = post_data.copy()
3113+
data["_selected_action"] = [device1.pk, device3.pk]
3114+
data["device_group"] = str(dg2.pk)
3115+
with patch.object(Device, "_send_device_group_changed_signal") as mocked:
3116+
response = self.client.post(path, data, follow=True)
3117+
self.assertEqual(response.status_code, 200)
3118+
self.assertEqual(len(mocked.call_args_list), 1)
3119+
device1.refresh_from_db()
3120+
device3.refresh_from_db()
3121+
self.assertEqual(device1.group_id, dg2.id)
3122+
self.assertEqual(device3.group_id, dg1.id)
3123+
30413124
device2.organization = org2
30423125
device2.save()
30433126

openwisp_controller/config/tests/test_config.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,24 @@ def test_auto_cert_not_deleted_on_device_deactivation(self):
564564
self.assertEqual(config.templates.count(), 0)
565565
self.assertEqual(cert.revoked, True)
566566

567+
def test_certificate_updated_skipped_for_deactivated_config(self):
568+
self._create_template(type="vpn", vpn=self._create_vpn(), default=True)
569+
config = self._create_config(organization=self._get_org())
570+
cert = config.vpnclient_set.first().cert
571+
config.deactivate()
572+
config.refresh_from_db()
573+
self.assertEqual(config.status, "deactivating")
574+
# VpnClient is deleted on deactivation; cert is auto-revoked.
575+
self.assertEqual(config.vpnclient_set.count(), 0)
576+
# Un-revoke the cert so certificate_updated() bypasses the early
577+
# "if revoked: return" guard and hits the ObjectDoesNotExist path.
578+
cert.revoked = False
579+
cert.save()
580+
# Config status must not change: certificate_updated() returns early
581+
# because the VpnClient was deleted during deactivation.
582+
config.refresh_from_db()
583+
self.assertEqual(config.status, "deactivating")
584+
567585
def _get_vpn_context(self):
568586
self.test_create_cert()
569587
c = Config.objects.get(device__name="test-create-cert")

openwisp_controller/config/tests/test_controller.py

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -786,6 +786,22 @@ def test_device_consistent_registration_exists_no_config(self):
786786
d.refresh_from_db()
787787
self.assertIsNotNone(d.config)
788788

789+
@capture_any_output()
790+
def test_register_deactivated_device(self):
791+
device = self._create_device_config()
792+
device.key = TEST_CONSISTENT_KEY
793+
device.save()
794+
device.deactivate()
795+
original_os = device.os
796+
params = self._get_reregistration_payload(
797+
device, name=device.name, os="OpenWrt 22.03"
798+
)
799+
response = self.client.post(self.register_url, params)
800+
self.assertContains(response, "error: device deactivated", status_code=403)
801+
device.refresh_from_db()
802+
self.assertEqual(device.os, original_os)
803+
self.assertEqual(device.config.templates.count(), 0)
804+
789805
def test_device_registration_update_hw_info(self):
790806
d = self._create_device_config()
791807
d.key = TEST_CONSISTENT_KEY
@@ -1075,9 +1091,37 @@ def test_device_update_info(self):
10751091
self.assertEqual(d.model, params["model"])
10761092

10771093
def test_deactivated_device_update_info(self):
1078-
self._test_deactivating_deactivated_device_view(
1079-
"device_update_info", method="post", data={}
1080-
)
1094+
# Inventory metadata updates must be rejected for deactivated devices,
1095+
# including the transient "deactivating" state (which GetDeviceView does
1096+
# not exclude on its own).
1097+
self._create_template(required=True)
1098+
device = self._create_device_config()
1099+
url = reverse("controller:device_update_info", args=[device.pk])
1100+
params = {
1101+
"key": device.key,
1102+
"model": "TP-Link TL-WDR4300 v2",
1103+
"os": "OpenWrt 18.06-SNAPSHOT r7312-e60be11330",
1104+
"system": "Atheros AR9344 rev 3",
1105+
}
1106+
device.deactivate()
1107+
device.refresh_from_db()
1108+
self.assertEqual(device.config.status, "deactivating")
1109+
initial = (device.os, device.model, device.system)
1110+
# payload must differ from current values so a missed block is detectable
1111+
self.assertNotEqual((params["os"], params["model"], params["system"]), initial)
1112+
1113+
with self.subTest("rejected while deactivating"):
1114+
response = self.client.post(url, params)
1115+
self.assertEqual(response.status_code, 403)
1116+
self._check_header(response)
1117+
device.refresh_from_db()
1118+
# metadata must be left untouched
1119+
self.assertEqual((device.os, device.model, device.system), initial)
1120+
1121+
with self.subTest("not found once fully deactivated"):
1122+
device.config.set_status_deactivated()
1123+
response = self.client.post(url, params)
1124+
self.assertEqual(response.status_code, 404)
10811125

10821126
def test_device_update_info_bad_uuid(self):
10831127
d = self._create_device_config()

openwisp_controller/config/tests/test_device.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,6 +512,27 @@ def test_device_group_changed_not_emitted_on_creation(self):
512512
self._create_device(name="test", organization=org, group=device_group)
513513
handler.assert_not_called()
514514

515+
def test_manage_devices_group_templates_skips_deactivated_devices(self):
516+
org = self._get_org()
517+
old_template = self._create_template(name="old-template", organization=org)
518+
new_template = self._create_template(name="new-template", organization=org)
519+
old_group = self._create_device_group(name="old-group", organization=org)
520+
new_group = self._create_device_group(name="new-group", organization=org)
521+
old_group.templates.add(old_template)
522+
new_group.templates.add(new_template)
523+
device = self._create_device(name="test", organization=org, group=old_group)
524+
device.deactivate()
525+
device.config.refresh_from_db()
526+
self.assertEqual(device.config.templates.count(), 0)
527+
Device.manage_devices_group_templates(device.pk, old_group.pk, new_group.pk)
528+
device.config.refresh_from_db()
529+
# Deactivated device config is skipped: adding templates to a cleared
530+
# config would misrepresent what has been applied to the device.
531+
self.assertEqual(device.config.templates.count(), 0)
532+
self.assertNotIn(new_template, device.config.templates.all())
533+
# Status must remain "deactivated" — no config push is initiated.
534+
self.assertEqual(device.config.status, "deactivated")
535+
515536
def test_device_field_changed_checks(self):
516537
self._create_device()
517538
device_group = self._create_device_group()

0 commit comments

Comments
 (0)