diff --git a/app/models.py b/app/models.py index 760c83e290b4ab2d90f272ca811897b603f390e3..9d641ee5d9d8897326b79379428658dc5b106d3e 100644 --- a/app/models.py +++ b/app/models.py @@ -1676,14 +1676,22 @@ class NetworkScope(CreatedMixin, db.Model): @validates("supernet") def validate_supernet(self, key, supernet): - """Ensure the supernet doesn't overlap existing supernets""" + """Ensure the supernet doesn't overlap existing supernets + + Also ensure it's a supernet of all existing networks (when editing) + """ supernet_address = ipaddress.ip_network(supernet) - existing_scopes = NetworkScope.query.all() + existing_scopes = NetworkScope.query.filter(NetworkScope.id != self.id).all() for existing_scope in existing_scopes: if supernet_address.overlaps(existing_scope.supernet_ip): raise ValidationError( f"{supernet} overlaps {existing_scope} ({existing_scope.supernet_ip})" ) + for network in self.networks: + if not network.network_ip.subnet_of(supernet_address): + raise ValidationError( + f"{network.network_ip} is not a subnet of {supernet}" + ) return supernet @validates("networks") @@ -1696,12 +1704,39 @@ class NetworkScope(CreatedMixin, db.Model): ) existing_networks = Network.query.filter_by(scope=self).all() for existing_network in existing_networks: + if existing_network.id == network.id: + # Same network added again during edit via admin interface + continue if network.network_ip.overlaps(existing_network.network_ip): raise ValidationError( f"{network.network_ip} overlaps {existing_network} ({existing_network.network_ip})" ) return network + @validates("first_vlan") + def validate_first_vlan(self, key, value): + """Ensure the first vlan is lower than any network vlan id""" + if value is None: + return value + for network in self.networks: + if int(value) > network.vlan_id: + raise ValidationError( + f"First vlan shall be lower than {network.vlan_name} vlan: {network.vlan_id}" + ) + return value + + @validates("last_vlan") + def validate_last_vlan(self, key, value): + """Ensure the last vlan is greater than any network vlan id""" + if value is None: + return value + for network in self.networks: + if int(value) < network.vlan_id: + raise ValidationError( + f"Last vlan shall be greater than {network.vlan_name} vlan: {network.vlan_id}" + ) + return value + @property def supernet_ip(self): return ipaddress.ip_network(self.supernet) diff --git a/app/network/views.py b/app/network/views.py index d8937cfaf0da13462454abf5a7d044aeefbfb39c..e3fd7bc90334db2d2bab3ea89feb08860d20c73f 100644 --- a/app/network/views.py +++ b/app/network/views.py @@ -627,6 +627,40 @@ def create_scope(): return render_template("network/create_scope.html", form=form) +@bp.route("/scopes/edit/<name>", methods=("GET", "POST")) +@login_groups_accepted("admin") +def edit_scope(name): + scope = models.NetworkScope.query.filter_by(name=name).first_or_404() + form = NetworkScopeForm(request.form, obj=scope) + if form.validate_on_submit(): + try: + for field in ( + "name", + "description", + "first_vlan", + "last_vlan", + "supernet", + "domain_id", + ): + setattr(scope, field, getattr(form, field).data) + except ValidationError as e: + # Check for error raised by model validation (not implemented in form validation) + current_app.logger.warning(f"{e}") + flash(f"{e}", "error") + return render_template("network/edit_scope.html", form=form) + current_app.logger.debug(f"Trying to update: {scope!r}") + try: + db.session.commit() + except sa.exc.IntegrityError as e: + db.session.rollback() + current_app.logger.warning(f"{e}") + flash(f"{e}", "error") + else: + flash(f"Network Scope {scope} updated!", "success") + return redirect(url_for("network.view_scope", name=scope.name)) + return render_template("network/edit_scope.html", form=form) + + @bp.route("/_retrieve_first_available_ip/<int:network_id>") @login_required def retrieve_first_available_ip(network_id): diff --git a/app/templates/network/edit_scope.html b/app/templates/network/edit_scope.html new file mode 100644 index 0000000000000000000000000000000000000000..cecafa7390a52e81423e107ee5bb496790b51b4c --- /dev/null +++ b/app/templates/network/edit_scope.html @@ -0,0 +1,30 @@ +{% extends "network/scopes.html" %} +{% from "_helpers.html" import render_field %} + +{% block title %}Edit {{ form.name.data }} network Scope{% endblock %} + +{% block scopes_nav %} + <li class="nav-item"> + <a class="nav-link" href="{{ url_for('network.view_scope', name=form.name.data) }}">View network scope</a> + </li> + <li class="nav-item"> + <a class="nav-link active" href="{{ url_for('network.edit_scope', name=form.name.data) }}">Edit network scope</a> + </li> +{% endblock %} + +{% block scopes_main %} + <form id="editScopeForm" method="POST"> + {{ form.hidden_tag() }} + {{ render_field(form.name) }} + {{ render_field(form.description) }} + {{ render_field(form.first_vlan) }} + {{ render_field(form.last_vlan) }} + {{ render_field(form.supernet) }} + {{ render_field(form.domain_id) }} + <div class="form-group row"> + <div class="col-sm-10"> + <button type="submit" class="btn btn-primary">Submit</button> + </div> + </div> + </form> +{%- endblock %} diff --git a/app/templates/network/view_scope.html b/app/templates/network/view_scope.html index 47cb6fb0b54aa9aa6b34ac303d0d6443d948e0d5..9e401dd49ed93656f0a529b870720c476d9acb79 100644 --- a/app/templates/network/view_scope.html +++ b/app/templates/network/view_scope.html @@ -7,6 +7,9 @@ <li class="nav-item"> <a class="nav-link active" href="{{ url_for('network.view_scope', name=scope.name) }}">View network scope</a> </li> +<li class="nav-item"> + <a class="nav-link" href="{{ url_for('network.edit_scope', name=scope.name) }}">Edit network scope</a> +</li> {% endblock %} {% block scopes_main %} diff --git a/tests/functional/test_models.py b/tests/functional/test_models.py index 187918145914b34bdb61e0be4b5bf5f16df8986a..9305190edd6e5c89412fb649508734f79f4f3010 100644 --- a/tests/functional/test_models.py +++ b/tests/functional/test_models.py @@ -950,6 +950,55 @@ def test_network_scope_overlapping(address, network_scope_factory): ) +def test_network_scope_supernet_validation(network_scope_factory, network_factory): + scope = network_scope_factory( + first_vlan=3800, last_vlan=4000, supernet="172.30.0.0/16" + ) + network1 = network_factory( + vlan_id=3800, + address="172.30.0.0/23", + first_ip="172.30.0.3", + last_ip="172.30.1.240", + scope=scope, + ) + address = "192.168.0.0/16" + with pytest.raises(ValidationError) as excinfo: + scope.supernet = "192.168.0.0/16" + assert f"{network1.network_ip} is not a subnet of {address}" in str(excinfo.value) + + +def test_network_scope_first_vlan_validation(network_scope_factory, network_factory): + scope = network_scope_factory( + first_vlan=200, last_vlan=400, supernet="172.30.0.0/16" + ) + network1 = network_factory( + vlan_id=220, + address="172.30.0.0/23", + first_ip="172.30.0.3", + last_ip="172.30.1.240", + scope=scope, + ) + with pytest.raises(ValidationError) as excinfo: + scope.first_vlan = 230 + assert f"First vlan shall be lower than {network1} vlan: 220" in str(excinfo.value) + + +def test_network_scope_last_vlan_validation(network_scope_factory, network_factory): + scope = network_scope_factory( + first_vlan=200, last_vlan=400, supernet="172.30.0.0/16" + ) + network1 = network_factory( + vlan_id=220, + address="172.30.0.0/23", + first_ip="172.30.0.3", + last_ip="172.30.1.240", + scope=scope, + ) + with pytest.raises(ValidationError) as excinfo: + scope.last_vlan = 210 + assert f"Last vlan shall be greater than {network1} vlan: 220" in str(excinfo.value) + + def test_host_sensitive_field_update_on_network_change( network_scope_factory, network_factory, interface_factory, host_factory ): diff --git a/tests/functional/test_web.py b/tests/functional/test_web.py index 07710920e0f071869a6b150392c0cabfea097b7d..4a15dbae0ca7c36cfad26713972ca4ada9772713 100644 --- a/tests/functional/test_web.py +++ b/tests/functional/test_web.py @@ -799,6 +799,47 @@ def test_edit_network( assert network.sensitive is True +def test_edit_network_scope( + logged_admin_client, domain_factory, network_scope_factory, network_factory +): + domain1 = domain_factory(name="lab.example.org") + name = "MyNetworks" + scope = network_scope_factory( + name=name, + first_vlan=100, + last_vlan=200, + supernet="192.168.0.0/16", + domain_id=domain1.id, + ) + network_factory( + vlan_name="my-network", + domain=domain1, + scope=scope, + vlan_id=110, + address="192.168.0.0/24", + first_ip="192.168.0.11", + last_ip="192.168.0.249", + gateway="192.168.0.254", + ) + new_domain = domain_factory(name="lab.example.eu") + form = { + "name": name, + "description": "Scope for MyNetworks", + "first_vlan": 105, + "last_vlan": 150, + "supernet": scope.supernet, + "domain_id": new_domain.id, + } + response = logged_admin_client.post(f"/network/scopes/edit/{name}", data=form) + assert response.status_code == 302 + # The scope was updated + updated_scope = models.NetworkScope.query.filter_by(name=name).first() + assert updated_scope is not None + assert updated_scope.first_vlan == 105 + assert updated_scope.last_vlan == 150 + assert updated_scope.domain == new_domain + + def test_create_item_invalid_ics_id(logged_rw_client): ics_id = "AAA1100" form = {"ics_id": ics_id, "serial_number": "12345"}