Skip to content

Commit 991e13f

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 991e13f

File tree

8 files changed

+317
-34
lines changed

8 files changed

+317
-34
lines changed

openwisp_controller/config/admin.py

Lines changed: 44 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,15 @@ 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(obj and obj._has_config() and not is_recover_view(request))
477+
kwargs["form"] = type(
478+
"ConfigInlineForm",
479+
(self.form,),
480+
{"readonly_backend": readonly_backend},
481+
)
482+
return super().get_formset(request, obj, **kwargs)
483+
460484
def get_queryset(self, request):
461485
qs = super().get_queryset(request)
462486
return qs.select_related(*self.change_select_related)
@@ -468,7 +492,14 @@ def _error_reason_field_conditional(self, obj, fields):
468492
return fields
469493

470494
def get_readonly_fields(self, request, obj):
471-
fields = super().get_readonly_fields(request, obj)
495+
fields = list(super().get_readonly_fields(request, obj))
496+
if (
497+
obj
498+
and obj._has_config()
499+
and not is_recover_view(request)
500+
and "backend" not in fields
501+
):
502+
fields.append("backend")
472503
return self._error_reason_field_conditional(obj, fields)
473504

474505
def get_fields(self, request, obj):
@@ -1082,6 +1113,12 @@ class TemplateAdmin(MultitenantAdminMixin, BaseConfigAdmin, SystemDefinedVariabl
10821113
readonly_fields = ["system_context"]
10831114
autocomplete_fields = ["vpn"]
10841115

1116+
def get_readonly_fields(self, request, obj=None):
1117+
fields = list(super().get_readonly_fields(request, obj))
1118+
if obj and not is_recover_view(request) and "backend" not in fields:
1119+
fields.append("backend")
1120+
return fields
1121+
10851122
@admin.action(permissions=["add"])
10861123
def clone_selected_templates(self, request, queryset):
10871124
selectable_orgs = None
@@ -1261,6 +1298,12 @@ class VpnAdmin(
12611298
"modified",
12621299
]
12631300

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

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;
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: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,32 @@
413413
});
414414
};
415415

416+
var getReadonlySchemaKey = function (schemas) {
417+
var backendLabel = $(".field-backend .readonly").first().text().trim();
418+
if (!backendLabel) {
419+
return false;
420+
}
421+
var normalize = function (value) {
422+
return String(value)
423+
.toLocaleLowerCase()
424+
.replace(/[^a-z0-9]/g, "");
425+
};
426+
var normalizedBackendLabel = normalize(backendLabel);
427+
var schemaKey = false;
428+
$.each(Object.keys(schemas), function (index, key) {
429+
var normalizedBackendKey = normalize(key.split(".").pop());
430+
if (
431+
normalizedBackendLabel === normalizedBackendKey ||
432+
normalizedBackendLabel.includes(normalizedBackendKey) ||
433+
normalizedBackendKey.includes(normalizedBackendLabel)
434+
) {
435+
schemaKey = key;
436+
return false;
437+
}
438+
});
439+
return schemaKey;
440+
};
441+
416442
var bindLoadUi = function () {
417443
$('.jsoneditor-raw:not([name*="__prefix__"]):not(.manual)').each(function (i, el) {
418444
// Add query parameters defined in the widget
@@ -439,7 +465,12 @@
439465
schemaSelector = "#id_backend, #id_config-0-backend";
440466
}
441467
var selector = $(schemaSelector),
468+
schemaKey = false;
469+
if (selector.length) {
442470
schemaKey = selector.val() || false;
471+
} else {
472+
schemaKey = getReadonlySchemaKey(schemas);
473+
}
443474
// load first time
444475
loadUi(el, schemaKey, schemas, true);
445476
// reload when selector is changed

0 commit comments

Comments
 (0)