From a653f0d92964f0bde28a22cacc2e0f6af89f0e52 Mon Sep 17 00:00:00 2001
From: Benjamin Bertrand <benjamin.bertrand@esss.se>
Date: Mon, 12 Feb 2018 10:33:51 +0100
Subject: [PATCH] Allow admin to create interface outside range

The network range defines the IP to be allocated.
This is to reserve some specific addresses (for gateway for example).

An admin user should be able to define an interface outside the network
range.

The IP address select field was replaced with a string field
(initialized to the first available IP).

Fixes INFRA-200
---
 app/models.py                | 18 ++++++++++++------
 app/network/forms.py         | 25 ++++++++++++++++++++++---
 app/network/views.py         |  8 ++++----
 app/static/js/hosts.js       | 24 ++++++++++--------------
 app/utils.py                 |  7 ++++++-
 tests/functional/test_api.py | 20 ++++++++++++++++++++
 6 files changed, 74 insertions(+), 28 deletions(-)

diff --git a/app/models.py b/app/models.py
index 3b5749b..4478aa1 100644
--- a/app/models.py
+++ b/app/models.py
@@ -469,8 +469,10 @@ class Network(CreatedMixin, db.Model):
     def validate_interfaces(self, key, interface):
         """Ensure the interface IP is in the network range"""
         addr, net = self.ip_in_network(interface.ip, self.address)
-        if addr < self.first or addr > self.last:
-            raise ValidationError(f'IP address {interface.ip} is not in range {self.first} - {self.last}')
+        # Admin user can create IP outside the defined range
+        if not utils.cse_current_user().is_admin:
+            if addr < self.first or addr > self.last:
+                raise ValidationError(f'IP address {interface.ip} is not in range {self.first} - {self.last}')
         return interface
 
     @validates('vlan_name')
@@ -561,11 +563,15 @@ class Interface(CreatedMixin, db.Model):
                            backref=db.backref('interfaces', lazy=True))
 
     def __init__(self, **kwargs):
-        # Automatically convert network to an instance of Network if it was passed
-        # as a string
-        if 'network' in kwargs:
+        # Always set self.network and not self.network_id to call validate_interfaces
+        network_id = kwargs.pop('network_id', None)
+        if network_id is not None:
+            kwargs['network'] = Network.query.get(network_id)
+        elif 'network' in kwargs:
+            # Automatically convert network to an instance of Network if it was passed
+            # as a string
             kwargs['network'] = utils.convert_to_model(kwargs['network'], Network, 'vlan_name')
-        # WARNING! Setting self.network will call validates_interfaces in the Network class
+        # WARNING! Setting self.network will call validate_interfaces in the Network class
         # For the validation to work, self.ip must be set before!
         # Ensure that ip is passed before network
         try:
diff --git a/app/network/forms.py b/app/network/forms.py
index 755626a..268bbb3 100644
--- a/app/network/forms.py
+++ b/app/network/forms.py
@@ -9,6 +9,7 @@ This module defines the network blueprint forms.
 :license: BSD 2-Clause, see LICENSE for more details.
 
 """
+import ipaddress
 from flask_login import current_user
 from wtforms import (SelectField, StringField, TextAreaField, IntegerField,
                      SelectMultipleField, BooleanField, validators)
@@ -44,6 +45,19 @@ def validate_tags(form, field):
                 raise validators.ValidationError(f'A gateway is already defined for network {network}: {existing_gateway}')
 
 
+def ip_in_network(form, field):
+    """Check that the IP is in the network"""
+    network_id_field = form['network_id']
+    network = models.Network.query.get(network_id_field.data)
+    ip = ipaddress.ip_address(field.data)
+    if ip not in network.network_ip:
+        raise validators.ValidationError(f'IP address {ip} is not in network {network.address}')
+    # Admin user can create IP outside the defined range
+    if current_user.is_authenticated and not current_user.is_admin:
+        if ip < network.first or ip > network.last:
+            raise validators.ValidationError(f'IP address {ip} is not in range {network.first} - {network.last}')
+
+
 class NoValidateSelectField(SelectField):
     """SelectField with no choices validation
 
@@ -122,9 +136,14 @@ class HostForm(CSEntryForm):
 class InterfaceForm(CSEntryForm):
     host_id = SelectField('Host')
     network_id = SelectField('Network')
-    # The list of IPs is dynamically created on the browser side
-    # depending on the selected network
-    ip = NoValidateSelectField('IP', choices=[])
+    ip = StringField(
+        'IP address',
+        validators=[validators.InputRequired(),
+                    validators.IPAddress(),
+                    ip_in_network,
+                    Unique(models.Interface, column='ip'),
+                    ],
+    )
     interface_name = StringField(
         'Interface name',
         description='name must be 2-20 characters long and contain only letters, numbers and dash',
diff --git a/app/network/views.py b/app/network/views.py
index eefcadc..258c680 100644
--- a/app/network/views.py
+++ b/app/network/views.py
@@ -290,16 +290,16 @@ def retrieve_hosts():
     return jsonify(data=data)
 
 
-@bp.route('/_retrieve_available_ips/<int:network_id>')
+@bp.route('/_retrieve_first_available_ip/<int:network_id>')
 @login_required
-def retrieve_available_ips(network_id):
+def retrieve_first_available_ip(network_id):
     try:
         network = models.Network.query.get(network_id)
     except sa.exc.DataError:
         current_app.logger.warning(f'Invalid network_id: {network_id}')
-        data = []
+        data = ''
     else:
-        data = [str(address) for address in network.available_ips()]
+        data = str(network.available_ips()[0])
     return jsonify(data=data)
 
 
diff --git a/app/static/js/hosts.js b/app/static/js/hosts.js
index 6a4ec41..62f5a59 100644
--- a/app/static/js/hosts.js
+++ b/app/static/js/hosts.js
@@ -1,17 +1,13 @@
 $(document).ready(function() {
 
-  function update_available_ips() {
-    // Retrieve available IPs for the selected network
-    // and update the IP select field
+  function set_default_ip() {
+    // Retrieve the first available IP for the selected network
+    // and update the IP field
     var network_id = $("#network_id").val();
     $.getJSON(
-      $SCRIPT_ROOT + "/network/_retrieve_available_ips/" + network_id,
+      $SCRIPT_ROOT + "/network/_retrieve_first_available_ip/" + network_id,
       function(json) {
-        var $ip = $("#ip");
-        $ip.empty();
-        $.map(json.data, function(option, index) {
-          $ip.append($("<option></option>").attr("value", option).text(option));
-        });
+        $("#ip").val(json.data);
       }
     );
   }
@@ -33,18 +29,18 @@ $(document).ready(function() {
     }
   }
 
-  // Populate IP select field on first page load for:
+  // Set the default IP on first page load for:
   // - register new host
   // - add interface
-  // Do NOT replace the IPs on edit interface page load!
+  // Do NOT replace the IP on edit interface page load!
   // (we have to keep the existing IP)
   if( $("#hostForm").length || $("#interfaceForm").length ) {
-    update_available_ips();
+    set_default_ip();
   }
 
-  // Update IP select field when changing network
+  // Set the default IP when changing network
   $("#network_id").on('change', function() {
-    update_available_ips();
+    set_default_ip();
   });
 
   // Enable / disable item_id field depending on type
diff --git a/app/utils.py b/app/utils.py
index 5c84920..b1913ab 100644
--- a/app/utils.py
+++ b/app/utils.py
@@ -21,13 +21,18 @@ from flask_login import current_user
 from flask_jwt_extended import get_current_user
 
 
+def cse_current_user():
+    """Return the current_user from flask_jwt_extended (API) or flask_login (web UI)"""
+    return get_current_user() or current_user
+
+
 def fetch_current_user_id():
     """Retrieve the user_id from flask_jwt_extended (API) or flask_login (web UI)"""
     # Return None if we are outside of request context.
     if _app_ctx_stack.top is None or _request_ctx_stack.top is None:
         return None
     # Try to get the user from both flask_jwt_extended and flask_login
-    user = get_current_user() or current_user
+    user = cse_current_user()
     try:
         return user.id
     except AttributeError:
diff --git a/tests/functional/test_api.py b/tests/functional/test_api.py
index 2f5fa75..e725def 100644
--- a/tests/functional/test_api.py
+++ b/tests/functional/test_api.py
@@ -654,6 +654,26 @@ def test_create_interface_ip_not_in_network(client, network_factory, user_token)
     check_response_message(response, 'IP address 192.168.2.4 is not in network 192.168.1.0/24', 422)
 
 
+def test_create_interface_ip_not_in_range(client, network_factory, user_token):
+    network = network_factory(address='192.168.1.0/24', first_ip='192.168.1.10', last_ip='192.168.1.250')
+    # IP address not in range
+    data = {'network': network.vlan_name,
+            'ip': '192.168.1.4',
+            'name': 'hostname'}
+    response = post(client, f'{API_URL}/network/interfaces', data=data, token=user_token)
+    check_response_message(response, 'IP address 192.168.1.4 is not in range 192.168.1.10 - 192.168.1.250', 422)
+
+
+def test_create_interface_ip_not_in_range_as_admin(client, network_factory, admin_token):
+    network = network_factory(address='192.168.1.0/24', first_ip='192.168.1.10', last_ip='192.168.1.250')
+    # IP address not in range
+    data = {'network': network.vlan_name,
+            'ip': '192.168.1.4',
+            'name': 'hostname'}
+    response = post(client, f'{API_URL}/network/interfaces', data=data, token=admin_token)
+    assert response.status_code == 201
+
+
 def test_get_macs(client, mac_factory, readonly_token):
     # Create some macs
     mac1 = mac_factory()
-- 
GitLab