Skip to content

Commit 6bebb4e

Browse files
committed
[fix] Disallow changing configuration backend from UI #789
Make the backend read-only on Django admin change forms for device configuration, template, and VPN server, while keeping it selectable on create forms. Also add recover-view regression coverage and ensure device inline backend changes are ignored on change-form submissions. Fixes #789
1 parent 8cf6733 commit 6bebb4e

File tree

8 files changed

+395
-34
lines changed

8 files changed

+395
-34
lines changed

openwisp_controller/config/admin.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,15 @@
6666
from django.contrib.admin import ModelAdmin
6767

6868

69+
def is_recover_view(request):
70+
resolver_match = getattr(request, "resolver_match", None)
71+
return getattr(request, "_recover_view", False) or bool(
72+
resolver_match
73+
and resolver_match.url_name
74+
and resolver_match.url_name.endswith("_recover")
75+
)
76+
77+
6978
class SystemDefinedVariableMixin(object):
7079
def system_context(self, obj):
7180
system_context = obj.get_system_context()
@@ -349,6 +358,12 @@ class Meta:
349358

350359
class ConfigForm(AlwaysHasChangedMixin, BaseForm):
351360
_old_templates = None
361+
readonly_backend = False
362+
363+
def __init__(self, *args, **kwargs):
364+
super().__init__(*args, **kwargs)
365+
if self.readonly_backend and "backend" in self.fields:
366+
self.fields["backend"].disabled = True
352367

353368
def get_temp_model_instance(self, **options):
354369
config_model = self.Meta.model
@@ -457,6 +472,17 @@ class ConfigInline(
457472
verbose_name_plural = verbose_name
458473
multitenant_shared_relations = ("templates",)
459474

475+
def get_formset(self, request, obj=None, **kwargs):
476+
readonly_backend = bool(
477+
obj and obj._has_config() and not is_recover_view(request)
478+
)
479+
kwargs["form"] = type(
480+
"ConfigInlineForm",
481+
(self.form,),
482+
{"readonly_backend": readonly_backend},
483+
)
484+
return super().get_formset(request, obj, **kwargs)
485+
460486
def get_queryset(self, request):
461487
qs = super().get_queryset(request)
462488
return qs.select_related(*self.change_select_related)
@@ -468,7 +494,14 @@ def _error_reason_field_conditional(self, obj, fields):
468494
return fields
469495

470496
def get_readonly_fields(self, request, obj):
471-
fields = super().get_readonly_fields(request, obj)
497+
fields = list(super().get_readonly_fields(request, obj))
498+
if (
499+
obj
500+
and obj._has_config()
501+
and not is_recover_view(request)
502+
and "backend" not in fields
503+
):
504+
fields.append("backend")
472505
return self._error_reason_field_conditional(obj, fields)
473506

474507
def get_fields(self, request, obj):
@@ -1082,6 +1115,12 @@ class TemplateAdmin(MultitenantAdminMixin, BaseConfigAdmin, SystemDefinedVariabl
10821115
readonly_fields = ["system_context"]
10831116
autocomplete_fields = ["vpn"]
10841117

1118+
def get_readonly_fields(self, request, obj=None):
1119+
fields = list(super().get_readonly_fields(request, obj))
1120+
if obj and not is_recover_view(request) and "backend" not in fields:
1121+
fields.append("backend")
1122+
return fields
1123+
10851124
@admin.action(permissions=["add"])
10861125
def clone_selected_templates(self, request, queryset):
10871126
selectable_orgs = None
@@ -1261,6 +1300,12 @@ class VpnAdmin(
12611300
"modified",
12621301
]
12631302

1303+
def get_readonly_fields(self, request, obj=None):
1304+
fields = list(super().get_readonly_fields(request, obj))
1305+
if obj and not is_recover_view(request) and "backend" not in fields:
1306+
fields.append("backend")
1307+
return fields
1308+
12641309
class Media(BaseConfigAdmin):
12651310
js = list(BaseConfigAdmin.Media.js) + [f"{prefix}js/vpn.js"]
12661311

openwisp_controller/config/static/config/js/relevant_templates.js

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@ django.jQuery(function ($) {
1010
return isDeviceGroup() ? "templates" : "config-0-templates";
1111
},
1212
isAddingNewObject = function () {
13-
return isDeviceGroup()
14-
? !$(".add-form").length
15-
: $('input[name="config-0-id"]').val().length === 0;
13+
if (isDeviceGroup()) {
14+
return $(".add-form").length > 0;
15+
}
16+
var configIdField = $('input[name="config-0-id"]');
17+
return !configIdField.length || configIdField.val().length === 0;
1618
},
1719
getTemplateOptionElement = function (
1820
index,
@@ -123,11 +125,12 @@ django.jQuery(function ($) {
123125
},
124126
showRelevantTemplates = function () {
125127
var orgID = $(orgFieldSelector).val(),
126-
backend = isDeviceGroup() ? "" : $(backendFieldSelector).val(),
128+
backend = isDeviceGroup() ? "" : $(backendFieldSelector).val() || "",
129+
configID = $('input[name="config-0-id"]').val(),
127130
currentSelection = getSelectedTemplates();
128131

129132
// Hide templates if no organization or backend is selected
130-
if (!orgID || (!isDeviceGroup() && backend.length === 0)) {
133+
if (!orgID || (!isDeviceGroup() && backend.length === 0 && !configID)) {
131134
resetTemplateOptions();
132135
updateTemplateHelpText();
133136
return;
@@ -193,14 +196,23 @@ django.jQuery(function ($) {
193196
initTemplateField();
194197
var backendField = $(backendFieldSelector);
195198
$(orgFieldSelector).change(function () {
196-
// Only fetch templates when backend field is present
197-
if ($(backendFieldSelector).length > 0 || isDeviceGroup()) {
199+
// Fetch templates when backend can be determined either from
200+
// an editable backend field or from an existing config object.
201+
if (
202+
$(backendFieldSelector).length > 0 ||
203+
isDeviceGroup() ||
204+
!isAddingNewObject()
205+
) {
198206
showRelevantTemplates();
199207
}
200208
});
201209
// Change view: backendField is rendered on page load
202210
if (backendField.length > 0) {
203211
addChangeEventHandlerToBackendField();
212+
} else if (!isDeviceGroup() && !isAddingNewObject()) {
213+
// Change view for device config has readonly backend with no input element.
214+
// In this case the backend is inferred server-side from config_id.
215+
showRelevantTemplates();
204216
} else if (isDeviceGroup()) {
205217
// Initially request data to get templates
206218
initTemplateField();

openwisp_controller/config/static/config/js/vpn.js

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,18 @@ django.jQuery(function ($) {
3333
return el.parents(".form-row").eq(0);
3434
};
3535

36+
var getBackendValue = function () {
37+
var backendInput = $("#id_backend");
38+
if (backendInput.length && backendInput.val() !== undefined) {
39+
return String(backendInput.val()).toLocaleLowerCase();
40+
}
41+
var readonlyBackend = $(".field-backend .readonly").first().text().trim();
42+
return readonlyBackend.toLocaleLowerCase();
43+
};
44+
3645
var toggleRelatedFields = function () {
3746
// Show IP and Subnet field only for WireGuard backend
38-
var backendValue =
39-
$("#id_backend").val() === undefined
40-
? ""
41-
: $("#id_backend").val().toLocaleLowerCase().toLocaleLowerCase(),
47+
var backendValue = getBackendValue(),
4248
op;
4349
if (backendValue.includes("wireguard") || backendValue.includes("vxlan")) {
4450
op = "show";
@@ -62,10 +68,12 @@ django.jQuery(function ($) {
6268
};
6369

6470
// clean config when VPN backend is changed
65-
$("#id_backend").change(function () {
66-
$("#id_config").val("{}");
67-
toggleRelatedFields();
68-
});
71+
if ($("#id_backend").length) {
72+
$("#id_backend").change(function () {
73+
$("#id_config").val("{}");
74+
toggleRelatedFields();
75+
});
76+
}
6977

7078
toggleRelatedFields();
7179
});

openwisp_controller/config/static/config/js/widget.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,35 @@
413413
});
414414
};
415415

416+
var getReadonlySchemaKey = function (schemas) {
417+
// Match readonly backend labels to schema keys using normalize(),
418+
// then compare normalizedBackendLabel and normalizedBackendKey by
419+
// equality and bi-directional containment for label/key variations.
420+
var backendLabel = $(".field-backend .readonly").first().text().trim();
421+
if (!backendLabel) {
422+
return false;
423+
}
424+
var normalize = function (value) {
425+
return String(value)
426+
.toLocaleLowerCase()
427+
.replace(/[^a-z0-9]/g, "");
428+
};
429+
var normalizedBackendLabel = normalize(backendLabel);
430+
var schemaKey = false;
431+
$.each(Object.keys(schemas), function (index, key) {
432+
var normalizedBackendKey = normalize(key.split(".").pop());
433+
if (
434+
normalizedBackendLabel === normalizedBackendKey ||
435+
normalizedBackendLabel.includes(normalizedBackendKey) ||
436+
normalizedBackendKey.includes(normalizedBackendLabel)
437+
) {
438+
schemaKey = key;
439+
return false;
440+
}
441+
});
442+
return schemaKey;
443+
};
444+
416445
var bindLoadUi = function () {
417446
$('.jsoneditor-raw:not([name*="__prefix__"]):not(.manual)').each(function (i, el) {
418447
// Add query parameters defined in the widget
@@ -439,7 +468,12 @@
439468
schemaSelector = "#id_backend, #id_config-0-backend";
440469
}
441470
var selector = $(schemaSelector),
471+
schemaKey = false;
472+
if (selector.length) {
442473
schemaKey = selector.val() || false;
474+
} else {
475+
schemaKey = getReadonlySchemaKey(schemas);
476+
}
443477
// load first time
444478
loadUi(el, schemaKey, schemas, true);
445479
// reload when selector is changed

0 commit comments

Comments
 (0)