From 8cf5e1fc4bcd62561f35cee4330dcab2dfecb011 Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand <benjamin.bertrand@ess.eu> Date: Thu, 30 Jan 2020 15:19:03 +0100 Subject: [PATCH] Add sensitive field to Network class JIRA INFRA-1671 --- app/api/network.py | 2 + app/models.py | 32 ++++++++++ app/network/forms.py | 1 + app/search.py | 17 ++++++ app/templates/network/create_network.html | 1 + app/templates/network/networks.html | 2 + app/templates/network/view_network.html | 2 + ...492f46f_add_sensitive_column_to_network.py | 27 +++++++++ tests/functional/test_api.py | 58 ++++++++----------- tests/functional/test_models.py | 27 +++++++++ tests/functional/test_search.py | 17 ++++++ tests/functional/test_web.py | 2 +- 12 files changed, 152 insertions(+), 36 deletions(-) create mode 100644 migrations/versions/acd72492f46f_add_sensitive_column_to_network.py diff --git a/app/api/network.py b/app/api/network.py index 73b9d10..0e95f45 100644 --- a/app/api/network.py +++ b/app/api/network.py @@ -79,6 +79,8 @@ def create_network(): :jsonparam domain_id: (optional) primary key of the domain [default: scope domain] :jsonparam admin_only: (optional) boolean to restrict the network to admin users [default: False] :type admin_only: bool + :jsonparam sensitive: hide the network and all hosts if True (for non admin) [default: False] + :type sensitive: bool :jsonparam description: (optional) description """ return utils.create_generic_model( diff --git a/app/models.py b/app/models.py index f5a2601..9dffdd6 100644 --- a/app/models.py +++ b/app/models.py @@ -832,6 +832,7 @@ class Network(CreatedMixin, db.Model): gateway = db.Column(postgresql.INET, nullable=False, unique=True) description = db.Column(db.Text) admin_only = db.Column(db.Boolean, nullable=False, default=False) + sensitive = db.Column(db.Boolean, nullable=False, default=False) scope_id = db.Column(db.Integer, db.ForeignKey("network_scope.id"), nullable=False) domain_id = db.Column(db.Integer, db.ForeignKey("domain.id"), nullable=False) @@ -951,6 +952,7 @@ class Network(CreatedMixin, db.Model): "gateway": self.gateway, "description": self.description, "admin_only": self.admin_only, + "sensitive": self.sensitive, "scope": utils.format_field(self.scope), "domain": str(self.domain), "interfaces": [str(interface) for interface in self.interfaces], @@ -1186,6 +1188,7 @@ class Host(CreatedMixin, SearchableMixin, db.Model): }, "ansible_vars": {"type": "flattened"}, "ansible_groups": {"type": "text", "fields": {"keyword": {"type": "keyword"}}}, + "sensitive": {"type": "boolean"}, } # id shall be defined here to be used by SQLAlchemy-Continuum @@ -1270,6 +1273,14 @@ class Host(CreatedMixin, SearchableMixin, db.Model): except AttributeError: return None + @property + def sensitive(self): + """Return True if the host is on a sensitive network""" + try: + return self.main_network.sensitive + except AttributeError: + return False + @property def fqdn(self): """Return the host fully qualified domain name @@ -1342,6 +1353,7 @@ class Host(CreatedMixin, SearchableMixin, db.Model): "interfaces": [str(interface) for interface in self.interfaces], "ansible_vars": self.ansible_vars, "ansible_groups": [str(group) for group in self.ansible_groups], + "sensitive": self.sensitive, } ) if recursive: @@ -1856,6 +1868,26 @@ def before_flush(session, flush_context, instances): trigger_core_services_update(session) +@sa.event.listens_for(Network.sensitive, "set") +def update_host_sensitive_field(target, value, oldvalue, initiator): + """Update the host sensitive field in elasticsearch based on the Network value + + Updating the network won't trigger any update of the hosts as sensitive is just + a property (based on host.main_interface.network). + We have to force the update in elasticsearch index. + """ + if value != oldvalue: + current_app.logger.debug(f"Network {target} sensitive value changed to {value}") + index = "host" + current_app.config["ELASTICSEARCH_INDEX_SUFFIX"] + for interface in target.interfaces: + current_app.logger.debug( + f"Update sensitive to {value} for {interface.host}" + ) + # We can't use interface.host.to_dict() because the property host.sensitive + # doesn't have the new value yet at this time + search.update_document(index, interface.host.id, {"sensitive": value}) + + # call configure_mappers after defining all the models # required by sqlalchemy_continuum sa.orm.configure_mappers() diff --git a/app/network/forms.py b/app/network/forms.py index 26aba27..71180a6 100644 --- a/app/network/forms.py +++ b/app/network/forms.py @@ -116,6 +116,7 @@ class NetworkForm(CSEntryForm): gateway = NoValidateSelectField("Gateway IP", choices=[]) domain_id = SelectField("Domain") admin_only = BooleanField("Admin only") + sensitive = BooleanField("Sensitive") def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/app/search.py b/app/search.py index 5459872..f2a6f01 100644 --- a/app/search.py +++ b/app/search.py @@ -70,3 +70,20 @@ def query_index(index, query, page=1, per_page=20, sort=None): search = current_app.elasticsearch.search(**kwargs) ids = [int(hit["_id"]) for hit in search["hits"]["hits"]] return ids, search["hits"]["total"]["value"] + + +def update_document(index, id, partial_doc): + """Update partially a document + + :param index: elasticsearch index + :param id: document id + :param dict partial_doc: fields to update + """ + if not current_app.elasticsearch: + return + current_app.elasticsearch.update( + index=index, + id=id, + body={"doc": partial_doc}, + refresh=current_app.config["ELASTICSEARCH_REFRESH"], + ) diff --git a/app/templates/network/create_network.html b/app/templates/network/create_network.html index 72cd6da..fb2e52f 100644 --- a/app/templates/network/create_network.html +++ b/app/templates/network/create_network.html @@ -17,6 +17,7 @@ {{ render_field(form.gateway) }} {{ render_field(form.domain_id) }} {{ render_field(form.admin_only) }} + {{ render_field(form.sensitive) }} <div class="form-group row"> <div class="col-sm-10"> <button type="submit" class="btn btn-primary">Submit</button> diff --git a/app/templates/network/networks.html b/app/templates/network/networks.html index a304b74..ae98b32 100644 --- a/app/templates/network/networks.html +++ b/app/templates/network/networks.html @@ -33,6 +33,7 @@ <th>Network scope</th> <th>Domain</th> <th>Admin only</th> + <th>Sensitive</th> </tr> </thead> <tbody> @@ -48,6 +49,7 @@ <td>{{ link_to_scope(network.scope) }}</td> <td>{{ network.domain }}</td> <td>{{ network.admin_only }}</td> + <td>{{ network.sensitive }}</td> </tr> {% endfor %} </tbody> diff --git a/app/templates/network/view_network.html b/app/templates/network/view_network.html index 6252479..31276ac 100644 --- a/app/templates/network/view_network.html +++ b/app/templates/network/view_network.html @@ -37,6 +37,8 @@ <dd class="col-sm-9">{{ network.domain }}</dd> <dt class="col-sm-3">Admin only</dt> <dd class="col-sm-9">{{ network.admin_only }}</dd> + <dt class="col-sm-3">Sensitive</dt> + <dd class="col-sm-9">{{ network.sensitive }}</dd> <dt class="col-sm-3">Hosts</dt> <dd class="col-sm-9">{{ link_to_interfaces_host(network.interfaces) }}</dd> <dt class="col-sm-3">Created by</dt> diff --git a/migrations/versions/acd72492f46f_add_sensitive_column_to_network.py b/migrations/versions/acd72492f46f_add_sensitive_column_to_network.py new file mode 100644 index 0000000..4d39a30 --- /dev/null +++ b/migrations/versions/acd72492f46f_add_sensitive_column_to_network.py @@ -0,0 +1,27 @@ +"""Add sensitive column to Network + +Revision ID: acd72492f46f +Revises: 33720bfb353a +Create Date: 2020-01-30 14:12:54.923114 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "acd72492f46f" +down_revision = "33720bfb353a" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column( + "network", + sa.Column("sensitive", sa.Boolean(), nullable=False, server_default="False"), + ) + + +def downgrade(): + op.drop_column("network", "sensitive") diff --git a/tests/functional/test_api.py b/tests/functional/test_api.py index d4276e4..5e0c580 100644 --- a/tests/functional/test_api.py +++ b/tests/functional/test_api.py @@ -60,6 +60,7 @@ HOST_KEYS = { "created_at", "updated_at", "user", + "sensitive", } INTERFACE_KEYS = { "id", @@ -78,6 +79,26 @@ INTERFACE_KEYS = { "updated_at", "user", } +NETWORK_KEYS = { + "id", + "vlan_name", + "vlan_id", + "address", + "netmask", + "broadcast", + "first_ip", + "last_ip", + "gateway", + "description", + "admin_only", + "sensitive", + "scope", + "domain", + "interfaces", + "created_at", + "updated_at", + "user", +} def get(client, url, token=None): @@ -736,25 +757,7 @@ def test_create_network(client, admin_token, network_scope_factory): } response = post(client, f"{API_URL}/network/networks", data=data, token=admin_token) assert response.status_code == 201 - assert { - "id", - "vlan_name", - "vlan_id", - "address", - "netmask", - "broadcast", - "first_ip", - "last_ip", - "gateway", - "description", - "admin_only", - "scope", - "domain", - "interfaces", - "created_at", - "updated_at", - "user", - } == set(response.get_json().keys()) + assert NETWORK_KEYS == set(response.get_json().keys()) assert response.get_json()["vlan_name"] == "network1" assert response.get_json()["vlan_id"] == 1600 assert response.get_json()["address"] == "172.16.1.0/24" @@ -1561,22 +1564,7 @@ def test_create_host(client, device_type_factory, user_token): data = {"name": "my-hostname", "device_type": device_type.name} response = post(client, f"{API_URL}/network/hosts", data=data, token=user_token) assert response.status_code == 201 - assert { - "id", - "name", - "fqdn", - "is_ioc", - "device_type", - "model", - "description", - "items", - "interfaces", - "ansible_vars", - "ansible_groups", - "created_at", - "updated_at", - "user", - } == set(response.get_json().keys()) + assert HOST_KEYS == set(response.get_json().keys()) assert response.get_json()["name"] == data["name"] # Check that name shall be unique diff --git a/tests/functional/test_models.py b/tests/functional/test_models.py index 31bf839..4eadeb3 100644 --- a/tests/functional/test_models.py +++ b/tests/functional/test_models.py @@ -931,3 +931,30 @@ def test_network_scope_overlapping(address, network_scope_factory): assert f"{address} overlaps {scope.name} ({scope.supernet_ip})" in str( excinfo.value ) + + +def test_host_sensitive_field_update_on_network_change( + network_scope_factory, network_factory, interface_factory, host_factory +): + scope = network_scope_factory( + first_vlan=3800, last_vlan=4000, supernet="192.168.0.0/16" + ) + network = network_factory( + vlan_id=3800, + address="192.168.1.0/24", + first_ip="192.168.1.10", + last_ip="192.168.1.250", + sensitive=False, + scope=scope, + ) + name = "host1" + host = host_factory(name=name) + interface_factory(name=name, host=host, ip="192.168.1.20", network=network) + # There is no sensitive host + instances, nb = models.Host.search("sensitive:true") + assert nb == 0 + # Updating the network should update the host in the elasticsearch index + network.sensitive = True + instances, nb = models.Host.search("sensitive:true") + assert nb == 1 + assert instances[0].name == name diff --git a/tests/functional/test_search.py b/tests/functional/test_search.py index ef74a13..59ed0db 100644 --- a/tests/functional/test_search.py +++ b/tests/functional/test_search.py @@ -68,3 +68,20 @@ def test_query_index(): # Test query sort ids, total = search.query_index("index-test", "*", sort="name.keyword") assert ids == [2, 1] + + +def test_update_document(db): + # Create a document + index = "index-test" + id = 4 + name = "a name" + description = "just an example" + model = MyModel(id, name, description) + search.add_to_index(index, model.to_dict()) + res = db.app.elasticsearch.get(index="index-test", id=id) + assert res["_source"] == {"name": name, "description": description} + # Update the name field (description doesn't change) + new_name = "new name" + search.update_document(index, id, {"name": new_name}) + res = db.app.elasticsearch.get(index=index, id=id) + assert res["_source"] == {"name": new_name, "description": description} diff --git a/tests/functional/test_web.py b/tests/functional/test_web.py index dcee73e..93d38f6 100644 --- a/tests/functional/test_web.py +++ b/tests/functional/test_web.py @@ -245,7 +245,7 @@ def test_retrieve_hosts(logged_client, interface_factory, host_factory): response = logged_client.post("/network/_retrieve_hosts") hosts = response.get_json()["data"] assert {host1.name, host2.name} == set(host["name"] for host in hosts) - assert len(hosts[0]) == 14 + assert len(hosts[0]) == 15 assert len(hosts[0]["interfaces"][0]) == 15 -- GitLab