Skip to content

Commit 4651ba3

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 45b24b6 commit 4651ba3

File tree

9 files changed

+443
-35
lines changed

9 files changed

+443
-35
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: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,29 @@ 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 readonlyBackendEl = $(".field-backend .readonly").first();
42+
if (!readonlyBackendEl.length) {
43+
return "";
44+
}
45+
var readonlyBackend = readonlyBackendEl.data("backend");
46+
if (
47+
readonlyBackend === undefined ||
48+
readonlyBackend === null ||
49+
String(readonlyBackend).trim() === ""
50+
) {
51+
readonlyBackend = readonlyBackendEl.text().trim();
52+
}
53+
return String(readonlyBackend).toLocaleLowerCase();
54+
};
55+
3656
var toggleRelatedFields = function () {
3757
// Show IP and Subnet field only for WireGuard backend
38-
var backendValue =
39-
$("#id_backend").val() === undefined
40-
? ""
41-
: $("#id_backend").val().toLocaleLowerCase().toLocaleLowerCase(),
58+
var backendValue = getBackendValue(),
4259
op;
4360
if (backendValue.includes("wireguard") || backendValue.includes("vxlan")) {
4461
op = "show";
@@ -62,10 +79,12 @@ django.jQuery(function ($) {
6279
};
6380

6481
// clean config when VPN backend is changed
65-
$("#id_backend").change(function () {
66-
$("#id_config").val("{}");
67-
toggleRelatedFields();
68-
});
82+
if ($("#id_backend").length) {
83+
$("#id_backend").change(function () {
84+
$("#id_config").val("{}");
85+
toggleRelatedFields();
86+
});
87+
}
6988

7089
toggleRelatedFields();
7190
});

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

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

416+
var getReadonlySchemaKey = function (schemas) {
417+
var readonlyBackendEl = $(".field-backend .readonly").first();
418+
if (!readonlyBackendEl.length) {
419+
return false;
420+
}
421+
var backendValue = String(
422+
readonlyBackendEl.data("backend") || readonlyBackendEl.attr("data-backend") || "",
423+
).trim();
424+
if (backendValue) {
425+
if (schemas[backendValue] !== undefined) {
426+
return backendValue;
427+
}
428+
var normalizedBackendValue = backendValue.toLocaleLowerCase(),
429+
directSchemaKey = false;
430+
$.each(Object.keys(schemas), function (index, key) {
431+
if (String(key).toLocaleLowerCase() === normalizedBackendValue) {
432+
directSchemaKey = key;
433+
return false;
434+
}
435+
});
436+
if (directSchemaKey) {
437+
return directSchemaKey;
438+
}
439+
}
440+
// Fallback for deployments that do not expose data-backend yet.
441+
// Match readonly backend labels to schema keys using normalize().
442+
var backendLabel = readonlyBackendEl.text().trim();
443+
if (!backendLabel) {
444+
return false;
445+
}
446+
var normalize = function (value) {
447+
return String(value)
448+
.toLocaleLowerCase()
449+
.replace(/[^a-z0-9]/g, "");
450+
};
451+
var normalizedBackendLabel = normalize(backendLabel);
452+
var schemaKey = false;
453+
$.each(Object.keys(schemas), function (index, key) {
454+
var normalizedBackendKey = normalize(key.split(".").pop());
455+
if (
456+
normalizedBackendLabel === normalizedBackendKey ||
457+
normalizedBackendLabel.includes(normalizedBackendKey) ||
458+
normalizedBackendKey.includes(normalizedBackendLabel)
459+
) {
460+
schemaKey = key;
461+
return false;
462+
}
463+
});
464+
return schemaKey;
465+
};
466+
416467
var bindLoadUi = function () {
417468
$('.jsoneditor-raw:not([name*="__prefix__"]):not(.manual)').each(function (i, el) {
418469
// Add query parameters defined in the widget
@@ -439,7 +490,12 @@
439490
schemaSelector = "#id_backend, #id_config-0-backend";
440491
}
441492
var selector = $(schemaSelector),
493+
schemaKey = false;
494+
if (selector.length) {
442495
schemaKey = selector.val() || false;
496+
} else {
497+
schemaKey = getReadonlySchemaKey(schemas);
498+
}
443499
// load first time
444500
loadUi(el, schemaKey, schemas, true);
445501
// reload when selector is changed

openwisp_controller/config/templates/admin/config/change_form.html

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,16 @@
6464
{% block content %}
6565
<div class="{% if not add %}change-form{% else %}add-form{% endif %}">
6666
{{ block.super }}
67+
{% if not add and original %}
68+
{% firstof original.config.backend original.backend "" as readonly_backend %}
69+
{% if readonly_backend %}
70+
<script>
71+
django
72+
.jQuery(".field-backend .readonly")
73+
.attr("data-backend", "{{ readonly_backend|escapejs }}");
74+
</script>
75+
{% endif %}
76+
{% endif %}
6777
<div class="djnjc-overlay">
6878
<div class="inner"></div>
6979
</div>

0 commit comments

Comments
 (0)