From 69d0ae79397f7a160233116440c6e20560aa6c55 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 9 May 2025 12:15:44 -0700 Subject: [PATCH 01/27] OBS-1046 add base for Client Credentials --- docker/docker-compose.yaml | 2 +- docker/netbox/env/netbox.env | 2 +- netbox_diode_plugin/models.py | 16 ++++++ netbox_diode_plugin/navigation.py | 26 ++++----- netbox_diode_plugin/urls.py | 6 +++ netbox_diode_plugin/views.py | 87 ++++++++++++++++++++++++++++++- 6 files changed, 124 insertions(+), 15 deletions(-) diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 88b6702..986f50a 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -20,7 +20,7 @@ services: volumes: - ./netbox/docker-entrypoint.sh:/opt/netbox/docker-entrypoint.sh:z,ro - ./netbox/nginx-unit.json:/opt/netbox/nginx-unit.json:z,ro - - ../netbox_diode_plugin:/opt/netbox/netbox/netbox_diode_plugin:ro + - ../netbox_diode_plugin:/opt/netbox/netbox/netbox_diode_plugin:z,rw - ./netbox/launch-netbox.sh:/opt/netbox/launch-netbox.sh:z,ro - ./netbox/plugins_dev.py:/etc/netbox/config/plugins.py:z,ro - ./coverage:/opt/netbox/netbox/coverage:z,rw diff --git a/docker/netbox/env/netbox.env b/docker/netbox/env/netbox.env index 2215733..4920b18 100644 --- a/docker/netbox/env/netbox.env +++ b/docker/netbox/env/netbox.env @@ -36,6 +36,6 @@ SUPERUSER_EMAIL= SUPERUSER_NAME=admin SUPERUSER_PASSWORD=admin WEBHOOKS_ENABLED=true -RELOAD_NETBOX_ON_DIODE_PLUGIN_CHANGE=false +RELOAD_NETBOX_ON_DIODE_PLUGIN_CHANGE=true BASE_PATH=netbox/ DEBUG=False \ No newline at end of file diff --git a/netbox_diode_plugin/models.py b/netbox_diode_plugin/models.py index 9079f9e..ea4d223 100644 --- a/netbox_diode_plugin/models.py +++ b/netbox_diode_plugin/models.py @@ -40,4 +40,20 @@ def get_absolute_url(self): return reverse("plugins:netbox_diode_plugin:settings") +class ClientCredentials(models.Model): + """ + Dummy model to allow for permissions, saved filters, etc.. + """ + + class Meta: + """Meta class.""" + + managed = False + + default_permissions = () + + permissions = ( + ("view_clientcredentials", "Can view Client Credentials"), + ("add_clientcredentials", "Can perform actions on Client Credentials"), + ) diff --git a/netbox_diode_plugin/navigation.py b/netbox_diode_plugin/navigation.py index 4fb18ef..bdf762c 100644 --- a/netbox_diode_plugin/navigation.py +++ b/netbox_diode_plugin/navigation.py @@ -2,24 +2,26 @@ # Copyright 2025 NetBox Labs, Inc. """Diode NetBox Plugin - Navigation.""" +from django.utils.translation import gettext as _ from netbox.plugins import PluginMenu, PluginMenuItem -settings = { - "link": "plugins:netbox_diode_plugin:settings", - "link_text": "Settings", - "staff_only": True, -} - +_diode_menu_items = ( + PluginMenuItem( + link="plugins:netbox_diode_plugin:settings", + link_text=_("Settings"), + staff_only= True, + ), + PluginMenuItem( + link="plugins:netbox_diode_plugin:client_credentials_list", + link_text=_("Client Credentials"), + staff_only= True, + ), +) menu = PluginMenu( label="Diode", groups=( - ( - "Diode", - ( - PluginMenuItem(**settings), - ), - ), + (_("Diode"), _diode_menu_items), ), icon_class="mdi mdi-upload", ) diff --git a/netbox_diode_plugin/urls.py b/netbox_diode_plugin/urls.py index abe6a0b..7a44209 100644 --- a/netbox_diode_plugin/urls.py +++ b/netbox_diode_plugin/urls.py @@ -9,4 +9,10 @@ urlpatterns = ( path("settings/", views.SettingsView.as_view(), name="settings"), path("settings/edit/", views.SettingsEditView.as_view(), name="settings_edit"), + path("credentials/", views.ClientCredentialsListView.as_view(), name="client_credentials_list"), + path( + "credentials//", + views.ClientCredentialsDetailView.as_view(), + name="client_credentials_detail" + ), ) diff --git a/netbox_diode_plugin/views.py b/netbox_diode_plugin/views.py index b0e4a7a..a8dc356 100644 --- a/netbox_diode_plugin/views.py +++ b/netbox_diode_plugin/views.py @@ -1,6 +1,8 @@ #!/usr/bin/env python # Copyright 2025 NetBox Labs, Inc. """Diode NetBox Plugin - Views.""" +import logging + from django.conf import settings as netbox_settings from django.contrib import messages from django.contrib.auth import get_user_model @@ -10,14 +12,19 @@ from django.views.generic import View from netbox.plugins import get_plugin_config from netbox.views import generic +from utilities.htmx import htmx_partial +from utilities.permissions import get_permission_for_model from utilities.views import register_model_view from netbox_diode_plugin.forms import SettingsForm -from netbox_diode_plugin.models import Setting +from netbox_diode_plugin.models import ClientCredentials, Setting +from netbox_diode_plugin.tables import ClientCredentialsTable User = get_user_model() +logger = logging.getLogger(__name__) + def redirect_to_login(request): """Redirect to login view.""" redirect_url = netbox_settings.LOGIN_URL @@ -109,3 +116,81 @@ def post(self, request, *args, **kwargs): kwargs["pk"] = settings.pk return super().post(request, *args, **kwargs) + + +class BaseDiodeView(View): + + def check_authentication(self, request): + if not request.user.is_authenticated or not request.user.is_staff: + next_url = request.path + if not url_has_allowed_host_and_scheme(next_url, allowed_hosts=None): + next_url = "/" + + return redirect(f"{netbox_settings.LOGIN_URL}?next={next_url}") + + def get_required_permission(self): + return get_permission_for_model(self.model, "view") + +class ClientCredentialsListView(BaseDiodeView): + table = ClientCredentialsTable + template_name = "diode/client_credentials_list.html" + model = ClientCredentials + + def get_table_data(self): + data = [] + total = 0 + try: + # make API call + pass + except Exception as e: + logger.debug(f"Error loading client credentials error: {str(e)}") + messages.error(self.request, str(e)) + data = [] + total = 0 + + return total, data + + def get(self, request): + if ret := self.check_authentication(request): + return ret + + total, data = self.get_table_data() + table = self.table(data=data) # Pass the data to the table + + # If this is an HTMX request, return only the rendered table HTML + if htmx_partial(request): + if request.GET.get("embedded", False): + table.embedded = True + # Hide selection checkboxes + if "pk" in table.base_columns: + table.columns.hide("pk") + return render( + request, + "htmx/table.html", + { + "model": ClientCredentials, + "table": table, + }, + ) + + context = { + "model": ClientCredentials, + "table": table, + } + + return render(request, self.template_name, context) + +class ClientCredentialsDetailView(BaseDiodeView): + + def get(self, request, client_credentials_id): + if ret := self.check_authentication(request): + return ret + + # make API call + data = None + + context = { + "object": data, + } + return render(request, "diode/client_credentials_detail.html", context) + From 2a0bc626b7ab2db16dcad319e60a80406197e243 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 9 May 2025 16:35:59 -0700 Subject: [PATCH 02/27] OBS-1046 add base for Client Credentials --- netbox_diode_plugin/client.py | 38 ++++++++ netbox_diode_plugin/navigation.py | 2 +- netbox_diode_plugin/tables.py | 78 ++++++++++++++++ .../diode/client_credential_list.html | 79 ++++++++++++++++ netbox_diode_plugin/urls.py | 8 +- netbox_diode_plugin/views.py | 90 ++++++++++++++++--- 6 files changed, 274 insertions(+), 21 deletions(-) create mode 100644 netbox_diode_plugin/client.py create mode 100644 netbox_diode_plugin/tables.py create mode 100644 netbox_diode_plugin/templates/diode/client_credential_list.html diff --git a/netbox_diode_plugin/client.py b/netbox_diode_plugin/client.py new file mode 100644 index 0000000..98630f4 --- /dev/null +++ b/netbox_diode_plugin/client.py @@ -0,0 +1,38 @@ + + +def create_client(client_name, scope ): + ret = { + "client_name": "client_name", + "client_id": "client_id", + "client_secret": "client_secret", + "scope": "scope", + "created_at": "2025-03-14T15:16:17Z" + } + return ret + + +def delete_client(client_id): + pass + + +def list_clients(): + ret = { + "data": [ + { + "client_name": "My Agent 1", + "client_id": "my-agent-1-a038dfef", + "scope": "diode:ingest", + "created_at": "2025-03-14T15:16:17Z" + }, + { + "client_name": "US East 12", + "client_id": "us-east-12-f00fa3cd", + "scope": "diode:ingest", + "created_at": "2025-04-15T10:11:00Z" + } + ], + "next_page_token": "3", + } + return ret["data"] + + diff --git a/netbox_diode_plugin/navigation.py b/netbox_diode_plugin/navigation.py index bdf762c..cdc64ce 100644 --- a/netbox_diode_plugin/navigation.py +++ b/netbox_diode_plugin/navigation.py @@ -12,7 +12,7 @@ staff_only= True, ), PluginMenuItem( - link="plugins:netbox_diode_plugin:client_credentials_list", + link="plugins:netbox_diode_plugin:client_credential_list", link_text=_("Client Credentials"), staff_only= True, ), diff --git a/netbox_diode_plugin/tables.py b/netbox_diode_plugin/tables.py new file mode 100644 index 0000000..6ee0014 --- /dev/null +++ b/netbox_diode_plugin/tables.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python +# Copyright 2024 NetBox Labs Inc +"""Diode NetBox Plugin - Tables.""" +import logging + +import django_tables2 as tables +from django.urls import reverse +from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ +from netbox.tables import BaseTable, columns + + +class ClientCredentialsTable(BaseTable): + label = tables.Column( + verbose_name=_("Label"), + accessor="client_name", + ) + client_id = tables.Column( + verbose_name=_("Client ID"), + accessor="client_id", + ) + client_secret = tables.Column( + verbose_name=_("Client Secret"), + empty_values=(), + ) + actions = tables.Column( + verbose_name=_(""), + orderable=False, + empty_values=(), + attrs={ + "td": { + "class": "text-end", + } + }, + ) + + exempt_columns = ("actions") + embedded = False + + class Meta: + attrs = { + "class": "table table-hover object-list", + "td": {"class": "align-middle"}, + } + fields = None + default_columns = ( + "label", + "client_id", + "client_secret", + "actions", + ) + + empty_text = _("No Client Credentials to display") + footer = False + + def render_client_secret(self, value): + return "*****" + + + def render_actions(self, record): + delete_url = reverse( + "plugins:netbox_diode_plugin:client_credential_delete", + kwargs={"client_credential_id": record["client_id"]}, + ) + + buttons = f""" + + + + """ # noqa: E501 + + return mark_safe(buttons) diff --git a/netbox_diode_plugin/templates/diode/client_credential_list.html b/netbox_diode_plugin/templates/diode/client_credential_list.html new file mode 100644 index 0000000..a10245d --- /dev/null +++ b/netbox_diode_plugin/templates/diode/client_credential_list.html @@ -0,0 +1,79 @@ +{% extends 'generic/object_list.html' %} +{% load static %} +{% load buttons %} +{% load helpers %} +{% load humanize %} +{% load i18n %} + + +{% block page-header %} +
+
+ + {# Title #} +
+

{% block pagetitle %}{% trans 'Client Credentials' %}{% endblock %}

+
+ + {# Controls #} +
+ {% block controls %} + + {% endblock controls %} +
+ +
+
+{% endblock %} + +{% block title %}{% trans "Client Credentials" %}{% endblock %} + +{% block content %} + {# Object list tab #} +
+
+ {% csrf_token %} + {# "Select all" form #} + {% if table.paginator.num_pages > 1 %} +
+
+
+
+ + +
+
+
+
+
+
+ {% endif %} + +
+ {% csrf_token %} + + + {# Objects table #} +
+
+ {% include 'htmx/table.html' %} +
+
+ {# /Objects table #} + +
+
+ +
+ {# /Object list tab #} + +{% endblock content %} + diff --git a/netbox_diode_plugin/urls.py b/netbox_diode_plugin/urls.py index 7a44209..8933d41 100644 --- a/netbox_diode_plugin/urls.py +++ b/netbox_diode_plugin/urls.py @@ -9,10 +9,6 @@ urlpatterns = ( path("settings/", views.SettingsView.as_view(), name="settings"), path("settings/edit/", views.SettingsEditView.as_view(), name="settings_edit"), - path("credentials/", views.ClientCredentialsListView.as_view(), name="client_credentials_list"), - path( - "credentials//", - views.ClientCredentialsDetailView.as_view(), - name="client_credentials_detail" - ), + path("credentials/", views.ClientCredentialListView.as_view(), name="client_credential_list"), + path("credentials/delete//", views.ClientCredentialDeleteView.as_view(), name="client_credential_delete"), ) diff --git a/netbox_diode_plugin/views.py b/netbox_diode_plugin/views.py index a8dc356..19ecf10 100644 --- a/netbox_diode_plugin/views.py +++ b/netbox_diode_plugin/views.py @@ -19,6 +19,7 @@ from netbox_diode_plugin.forms import SettingsForm from netbox_diode_plugin.models import ClientCredentials, Setting from netbox_diode_plugin.tables import ClientCredentialsTable +from netbox_diode_plugin.client import list_clients, create_client, delete_client User = get_user_model() @@ -118,6 +119,21 @@ def post(self, request, *args, **kwargs): return super().post(request, *args, **kwargs) +class GetReturnURLMixin: + + def get_return_url(self, request): + + # First, see if `return_url` was specified as a query parameter or form data. Use this URL only if it's + # considered safe. + return_url = request.GET.get("return_url") or request.POST.get("return_url") + if return_url and url_has_allowed_host_and_scheme( + return_url, allowed_hosts=None + ): + return return_url + + return None + + class BaseDiodeView(View): def check_authentication(self, request): @@ -131,17 +147,15 @@ def check_authentication(self, request): def get_required_permission(self): return get_permission_for_model(self.model, "view") -class ClientCredentialsListView(BaseDiodeView): +class ClientCredentialListView(BaseDiodeView): table = ClientCredentialsTable - template_name = "diode/client_credentials_list.html" + template_name = "diode/client_credential_list.html" model = ClientCredentials def get_table_data(self): - data = [] - total = 0 try: - # make API call - pass + data = list_clients() + total = len(data) except Exception as e: logger.debug(f"Error loading client credentials error: {str(e)}") messages.error(self.request, str(e)) @@ -180,17 +194,65 @@ def get(self, request): return render(request, self.template_name, context) -class ClientCredentialsDetailView(BaseDiodeView): - def get(self, request, client_credentials_id): +class ClientCredentialDeleteView(GetReturnURLMixin, BaseDiodeView): + template_name = "diode/client_credential_delete.html" + + def get(self, request, deviation_id): if ret := self.check_authentication(request): return ret - # make API call - data = None + data = get_deviation_from_id(request, deviation_id) - context = { - "object": data, - } - return render(request, "diode/client_credentials_detail.html", context) + form = self.init_branch_form(request, data) + return render( + request, + self.template_name, + { + "object": data, + "form": form, + "return_url": self.get_return_url(request), + }, + ) + + def post(self, request, deviation_id): + if ret := self.check_authentication(request): + return ret + + form = BranchSelectForm(request.POST) + + if form.is_valid(): + branch_id = None + if "branch" in form.cleaned_data: + branch_id = form.cleaned_data.get("branch") + + try: + diode_api.deviation_rediff(deviation_id, branch_id) + messages.success(request, _("Deviation Rediffed")) + except ReconcilerClientError as e: + sanitized_deviation_id = deviation_id.replace("\n", "").replace( + "\r", "" + ) + logger.error( + f"Error rediffing deviation: {sanitized_deviation_id} error: {str(e)}" + ) + messages.error(request, str(e)) + + return redirect( + reverse( + "plugins:netbox_assurance_plugin:deviation", + kwargs={"deviation_id": deviation_id}, + ) + ) + + data = get_deviation_from_id(request, deviation_id) + return render( + request, + self.template_name, + { + "object": data, + "form": form, + "return_url": self.get_return_url(request), + }, + ) From f1fb759b70d05cbfa5f703a0fc4a654fb132c29a Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 12 May 2025 18:03:20 -0700 Subject: [PATCH 03/27] OBS-1046 add base for Client Credentials --- netbox_diode_plugin/client.py | 19 ++++++-- .../diode/client_credential_delete.html | 30 +++++++++++++ .../templates/diode/htmx/delete_form.html | 25 +++++++++++ netbox_diode_plugin/views.py | 44 ++++++++----------- 4 files changed, 89 insertions(+), 29 deletions(-) create mode 100644 netbox_diode_plugin/templates/diode/client_credential_delete.html create mode 100644 netbox_diode_plugin/templates/diode/htmx/delete_form.html diff --git a/netbox_diode_plugin/client.py b/netbox_diode_plugin/client.py index 98630f4..a86337d 100644 --- a/netbox_diode_plugin/client.py +++ b/netbox_diode_plugin/client.py @@ -1,8 +1,8 @@ -def create_client(client_name, scope ): +def create_client(request, client_name, scope): ret = { - "client_name": "client_name", + "client_name": "client_name1", "client_id": "client_id", "client_secret": "client_secret", "scope": "scope", @@ -11,11 +11,22 @@ def create_client(client_name, scope ): return ret -def delete_client(client_id): +def delete_client(request, client_id): pass -def list_clients(): +def get_client(request, client_id): + ret = { + "client_name": "client_name1", + "client_id": "client_id", + "client_secret": "client_secret", + "scope": "scope", + "created_at": "2025-03-14T15:16:17Z" + } + return ret + + +def list_clients(request): ret = { "data": [ { diff --git a/netbox_diode_plugin/templates/diode/client_credential_delete.html b/netbox_diode_plugin/templates/diode/client_credential_delete.html new file mode 100644 index 0000000..2ae85d2 --- /dev/null +++ b/netbox_diode_plugin/templates/diode/client_credential_delete.html @@ -0,0 +1,30 @@ +{% extends 'generic/_base.html' %} +{% load helpers %} +{% load form_helpers %} +{% load i18n %} + +{% comment %} +Blocks: + - title: Page title + - content: Primary page content + +Context: + - object: Python instance of the object being deleted + - form: The delete confirmation form + - form_url: URL for form submission (optional; defaults to current path) + - return_url: The URL to which the user is redirected after submitting the form +{% endcomment %} + +{% block title %} + {% trans "Delete" %} {{ object.client_name }}? +{% endblock %} + +{% block content %} + +{% endblock %} diff --git a/netbox_diode_plugin/templates/diode/htmx/delete_form.html b/netbox_diode_plugin/templates/diode/htmx/delete_form.html new file mode 100644 index 0000000..3dc798e --- /dev/null +++ b/netbox_diode_plugin/templates/diode/htmx/delete_form.html @@ -0,0 +1,25 @@ +{% load form_helpers %} +{% load i18n %} + +
+ {% csrf_token %} + + + +
diff --git a/netbox_diode_plugin/views.py b/netbox_diode_plugin/views.py index 19ecf10..ba7db6c 100644 --- a/netbox_diode_plugin/views.py +++ b/netbox_diode_plugin/views.py @@ -8,18 +8,21 @@ from django.contrib.auth import get_user_model from django.http import HttpResponseRedirect from django.shortcuts import redirect, render +from django.urls import reverse +from django.utils.translation import gettext as _ from django.utils.http import url_has_allowed_host_and_scheme from django.views.generic import View from netbox.plugins import get_plugin_config from netbox.views import generic from utilities.htmx import htmx_partial from utilities.permissions import get_permission_for_model +from utilities.forms import ConfirmationForm from utilities.views import register_model_view from netbox_diode_plugin.forms import SettingsForm from netbox_diode_plugin.models import ClientCredentials, Setting from netbox_diode_plugin.tables import ClientCredentialsTable -from netbox_diode_plugin.client import list_clients, create_client, delete_client +from netbox_diode_plugin.client import list_clients, get_client, delete_client User = get_user_model() @@ -152,9 +155,9 @@ class ClientCredentialListView(BaseDiodeView): template_name = "diode/client_credential_list.html" model = ClientCredentials - def get_table_data(self): + def get_table_data(self, request): try: - data = list_clients() + data = list_clients(request) total = len(data) except Exception as e: logger.debug(f"Error loading client credentials error: {str(e)}") @@ -168,7 +171,7 @@ def get(self, request): if ret := self.check_authentication(request): return ret - total, data = self.get_table_data() + total, data = self.get_table_data(request) table = self.table(data=data) # Pass the data to the table # If this is an HTMX request, return only the rendered table HTML @@ -198,60 +201,51 @@ def get(self, request): class ClientCredentialDeleteView(GetReturnURLMixin, BaseDiodeView): template_name = "diode/client_credential_delete.html" - def get(self, request, deviation_id): + def get(self, request, client_credential_id): if ret := self.check_authentication(request): return ret - data = get_deviation_from_id(request, deviation_id) - - form = self.init_branch_form(request, data) + data = get_client(request, client_credential_id) return render( request, self.template_name, { "object": data, - "form": form, + "object_type": "Client Credential", "return_url": self.get_return_url(request), }, ) - def post(self, request, deviation_id): + def post(self, request, client_credential_id): if ret := self.check_authentication(request): return ret - form = BranchSelectForm(request.POST) - + form = ConfirmationForm(initial=request.GET) if form.is_valid(): - branch_id = None - if "branch" in form.cleaned_data: - branch_id = form.cleaned_data.get("branch") try: - diode_api.deviation_rediff(deviation_id, branch_id) - messages.success(request, _("Deviation Rediffed")) - except ReconcilerClientError as e: - sanitized_deviation_id = deviation_id.replace("\n", "").replace( - "\r", "" - ) + delete_client(request, client_credential_id) + messages.success(request, _("Client deleted successfully")) + except Exception as e: logger.error( - f"Error rediffing deviation: {sanitized_deviation_id} error: {str(e)}" + f"Error deleting client: {client_credential_id} error: {str(e)}" ) messages.error(request, str(e)) return redirect( reverse( - "plugins:netbox_assurance_plugin:deviation", - kwargs={"deviation_id": deviation_id}, + "plugins:netbox_diode_plugin:client_credential_list", ) ) - data = get_deviation_from_id(request, deviation_id) + data = get_client(request, client_credential_id) return render( request, self.template_name, { "object": data, + "object_type": "Client Credential", "form": form, "return_url": self.get_return_url(request), }, From c427d24a64de85c600d54bcb4ed1d1131bac0350 Mon Sep 17 00:00:00 2001 From: Luke Tucker Date: Wed, 14 May 2025 20:42:58 -0400 Subject: [PATCH 04/27] feat: add api client for diode auth credentials api --- .gitignore | 2 + README.md | 3 + docker/docker-compose.yaml | 1 + docker/oauth2/secrets/.gitkeep | 0 netbox_diode_plugin/__init__.py | 9 ++ netbox_diode_plugin/client.py | 51 ++----- netbox_diode_plugin/diode/clients.py | 209 +++++++++++++++++++++++++++ netbox_diode_plugin/plugin_config.py | 36 ++++- 8 files changed, 269 insertions(+), 42 deletions(-) create mode 100644 docker/oauth2/secrets/.gitkeep create mode 100644 netbox_diode_plugin/diode/clients.py diff --git a/.gitignore b/.gitignore index b5821bd..53cce25 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,5 @@ dist/ # Docker docker/coverage !docker/netbox/env +docker/oauth2/secrets/* +!docker/oauth2/secrets/.gitkeep diff --git a/README.md b/README.md index 96f93b5..564bfd4 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,9 @@ PLUGINS_CONFIG = { # Username associated with changes applied via plugin "diode_username": "diode", + + # netbox-to-diode client_secret created during diode bootstrap. + "netbox_to_diode_client_secret": "..." }, } ``` diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 986f50a..94677dd 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -21,6 +21,7 @@ services: - ./netbox/docker-entrypoint.sh:/opt/netbox/docker-entrypoint.sh:z,ro - ./netbox/nginx-unit.json:/opt/netbox/nginx-unit.json:z,ro - ../netbox_diode_plugin:/opt/netbox/netbox/netbox_diode_plugin:z,rw + - ./oauth2/secrets:/run/secrets:z,ro - ./netbox/launch-netbox.sh:/opt/netbox/launch-netbox.sh:z,ro - ./netbox/plugins_dev.py:/etc/netbox/config/plugins.py:z,ro - ./coverage:/opt/netbox/netbox/coverage:z,rw diff --git a/docker/oauth2/secrets/.gitkeep b/docker/oauth2/secrets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/netbox_diode_plugin/__init__.py b/netbox_diode_plugin/__init__.py index 16f718d..3b3b48f 100644 --- a/netbox_diode_plugin/__init__.py +++ b/netbox_diode_plugin/__init__.py @@ -22,6 +22,15 @@ class NetBoxDiodePluginConfig(PluginConfig): # Default username associated with changes applied via plugin "diode_username": "diode", + + # client_id and client_secret for communication with Diode server. + # By default, the secret is read from a file /run/secrets/netbox_to_diode + # but may be specified directly as a string in netbox_to_diode_client_secret + "netbox_to_diode_client_id": "netbox-to-diode", + "netbox_to_diode_client_secret": None, + "secrets_path": "/run/secrets/", + "netbox_to_diode_client_secret_name": "netbox_to_diode", + "diode_max_auth_retries": 3, } diff --git a/netbox_diode_plugin/client.py b/netbox_diode_plugin/client.py index a86337d..f206224 100644 --- a/netbox_diode_plugin/client.py +++ b/netbox_diode_plugin/client.py @@ -1,49 +1,18 @@ +# !/usr/bin/env python +# Copyright 2025 NetBox Labs, Inc. +"""Diode NetBox Plugin - Client.""" +from netbox_diode_plugin.diode.clients import get_api_client def create_client(request, client_name, scope): - ret = { - "client_name": "client_name1", - "client_id": "client_id", - "client_secret": "client_secret", - "scope": "scope", - "created_at": "2025-03-14T15:16:17Z" - } - return ret - + return get_api_client().create_client(client_name, scope) def delete_client(request, client_id): - pass - - -def get_client(request, client_id): - ret = { - "client_name": "client_name1", - "client_id": "client_id", - "client_secret": "client_secret", - "scope": "scope", - "created_at": "2025-03-14T15:16:17Z" - } - return ret - + return get_api_client().delete_client(client_id) def list_clients(request): - ret = { - "data": [ - { - "client_name": "My Agent 1", - "client_id": "my-agent-1-a038dfef", - "scope": "diode:ingest", - "created_at": "2025-03-14T15:16:17Z" - }, - { - "client_name": "US East 12", - "client_id": "us-east-12-f00fa3cd", - "scope": "diode:ingest", - "created_at": "2025-04-15T10:11:00Z" - } - ], - "next_page_token": "3", - } - return ret["data"] - + response = get_api_client().list_clients() + return response["data"] +def get_client(request, client_id): + return get_api_client().get_client(client_id) \ No newline at end of file diff --git a/netbox_diode_plugin/diode/clients.py b/netbox_diode_plugin/diode/clients.py new file mode 100644 index 0000000..2efebd6 --- /dev/null +++ b/netbox_diode_plugin/diode/clients.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python +# Copyright 2025 NetBox Labs Inc +"""Diode NetBox Plugin - Diode - Auth.""" + +from dataclasses import dataclass +import datetime +import json +import logging +import requests +import threading +from urllib.parse import urlencode +from netbox_diode_plugin.plugin_config import ( + get_diode_auth_base_url, + get_diode_credentials, + get_diode_max_auth_retries, +) + +SCOPE_DIODE_READ = "diode:read" +SCOPE_DIODE_WRITE = "diode:write" + +logger = logging.getLogger("netbox.diode_data") + + +_client = None +_client_lock = threading.Lock() +def get_api_client(): + """Get the client API client.""" + global _client + global _client_lock + + with _client_lock: + if _client is None: + client_id, client_secret = get_diode_credentials() + max_auth_retries = get_diode_max_auth_retries() + _client = ClientAPI( + base_url=get_diode_auth_base_url(), + client_id=client_id, + client_secret=client_secret, + max_auth_retries=max_auth_retries, + ) + return _client + + +class ClientAPIError(Exception): + """Client API Error.""" + + def __init__(self, message: str, status_code: int = 500): + """Initialize the ClientAPIError.""" + self.message = message + self.status_code = status_code + super().__init__(self.message) + + def is_auth_error(self) -> bool: + """Check if the error is an authentication error.""" + return self.status_code == 401 or self.status_code == 403 + +class ClientAPI: + """Manages Diode Clients.""" + + def __init__(self, base_url: str, client_id: str, client_secret: str, max_auth_retries: int = 2): + """Initialize the ClientAPI.""" + self.base_url = base_url + self.client_id = client_id + self.client_secret = client_secret + + self._max_auth_retries = max_auth_retries + self._client_auth_token = None + self._client_auth_token_lock = threading.Lock() + + def create_client(self, name: str, scope: str) -> dict: + """Create a client.""" + for attempt in range(self._max_auth_retries): + token = None + try: + token = self._get_token() + url = self.base_url + "/clients" + headers = {"Authorization": f"Bearer {token}"} + data = { + "client_name": name, + "scope": scope, + } + response = requests.post(url, data=data, headers=headers) + if response.status_code != 200: + raise ClientAPIError("Failed to create client", response.status_code) + return response.json() + except ClientAPIError as e: + if e.is_auth_error() and attempt < self._max_auth_retries - 1: + logger.info(f"Retrying create_client due to unauthenticated error, attempt {attempt + 1}") + self._mark_client_auth_token_invalid(token) + continue + raise + raise ClientAPIError("Failed to create client: unexpected state", 500) + + def get_client(self, client_id: str) -> dict: + """Get a client.""" + for attempt in range(self._max_auth_retries): + token = None + try: + token = self._get_token() + url = self.base_url + f"/clients/{client_id}" + headers = {"Authorization": f"Bearer {token}"} + response = requests.get(url, headers=headers) + if response.status_code == 401 or response.status_code == 403: + raise ClientAPIError(f"Failed to get client {client_id}", response.status_code) + if response.status_code != 200: + raise ClientAPIError(f"Failed to get client {client_id}", response.status_code) + return response.json() + except ClientAPIError as e: + if e.is_auth_error() and attempt < self._max_auth_retries - 1: + logger.info(f"Retrying delete_client due to unauthenticated error, attempt {attempt + 1}") + self._mark_client_auth_token_invalid(token) + continue + raise + raise ClientAPIError(f"Failed to get client {client_id}: unexpected state") + + def delete_client(self, client_id: str) -> None: + """Delete a client.""" + for attempt in range(self._max_auth_retries): + token = None + try: + token = self._get_token() + url = self.base_url + f"/clients/{client_id}" + headers = {"Authorization": f"Bearer {token}"} + response = requests.delete(url, headers=headers) + if response.status_code == 401 or response.status_code == 403: + raise ClientAPIError(f"Failed to delete client {client_id}", response.status_code) + if response.status_code != 200: + raise ClientAPIError(f"Failed to delete client {client_id}", response.status_code) + return response.json() + except ClientAPIError as e: + if e.is_auth_error() and attempt < self._max_auth_retries - 1: + logger.info(f"Retrying delete_client due to unauthenticated error, attempt {attempt + 1}") + self._mark_client_auth_token_invalid(token) + continue + raise + raise ClientAPIError(f"Failed to delete client {client_id}: unexpected state") + + + def list_clients(self, page_token: str | None = None, page_size: int | None = None) -> list[dict]: + """List all clients.""" + for attempt in range(self._max_auth_retries): + token = None + try: + token = self._get_token() + url = self.base_url + "/clients" + headers = {"Authorization": f"Bearer {token}"} + params = {} + if page_token: + params["page_token"] = page_token + if page_size: + params["page_size"] = page_size + response = requests.get(url, headers=headers, params=params) + if response.status_code == 401 or response.status_code == 403: + raise ClientAPIError("Failed to get clients", response.status_code) + if response.status_code != 200: + raise ClientAPIError("Failed to get clients", response.status_code) + return response.json() + except ClientAPIError as e: + if e.is_auth_error() and attempt < self._max_auth_retries - 1: + logger.info(f"Retrying list_clients due to unauthenticated error, attempt {attempt + 1}") + self._mark_client_auth_token_invalid(token) + continue + raise + raise ClientAPIError("Failed to list clients: unexpected state") + + + def _get_token(self) -> str: + """Get a token for the Diode Auth Service.""" + with self._client_auth_token_lock: + if self._client_auth_token: + return self._client_auth_token + self._client_auth_token = self._authenticate() + return self._client_auth_token + + def _mark_client_auth_token_invalid(self, token: str): + """Mark a client auth token as invalid.""" + with self._client_auth_token_lock: + self._client_auth_token = None + + def _authenticate(self) -> str: + """Get a new access token for the Diode Auth Service.""" + headers = {"Content-Type": "application/x-www-form-urlencoded"} + data = urlencode( + { + "grant_type": "client_credentials", + "client_id": self.client_id, + "client_secret": self.client_secret, + "scope": f"{SCOPE_DIODE_READ} {SCOPE_DIODE_WRITE}", + } + ) + url = self.base_url + "/token" + try: + response = requests.post(url, data=data, headers=headers) + except Exception as e: + raise ClientAPIError(f"Failed to obtain access token: {e}", 401) from e + if response.status_code != 200: + raise ClientAPIError(f"Failed to obtain access token: {response.reason}", 401) + + try: + token_info = response.json() + except Exception as e: + raise ClientAPIError(f"Failed to parse access token response: {e}", 401) from e + + access_token = token_info.get("access_token") + if not access_token: + raise ClientAPIError(f"Failed to obtain access token for client {self._client_id}", 401) + + return access_token + diff --git a/netbox_diode_plugin/plugin_config.py b/netbox_diode_plugin/plugin_config.py index 60dbcad..3c15c0e 100644 --- a/netbox_diode_plugin/plugin_config.py +++ b/netbox_diode_plugin/plugin_config.py @@ -2,6 +2,8 @@ # Copyright 2025 NetBox Labs, Inc. """Diode NetBox Plugin - Plugin Settings.""" +import logging +import os from urllib.parse import urlparse from django.contrib.auth import get_user_model @@ -14,6 +16,7 @@ User = get_user_model() +logger = logging.getLogger("netbox.diode_data") def _parse_diode_target(target: str) -> tuple[str, str, bool]: """Parse the target into authority, path and tls_verify.""" @@ -31,6 +34,11 @@ def _parse_diode_target(target: str) -> tuple[str, str, bool]: def get_diode_auth_introspect_url(): """Returns the Diode Auth introspect URL.""" + diode_auth_base_url = get_diode_auth_base_url() + return f"{diode_auth_base_url}/introspect" + +def get_diode_auth_base_url(): + """Returns the Diode Auth service base URL.""" diode_target = get_plugin_config("netbox_diode_plugin", "diode_target") diode_target_override = get_plugin_config( "netbox_diode_plugin", "diode_target_override" @@ -42,8 +50,34 @@ def get_diode_auth_introspect_url(): scheme = "https" if tls_verify else "http" path = path.rstrip("/") - return f"{scheme}://{authority}{path}/auth/introspect" + return f"{scheme}://{authority}{path}/auth" + +def get_diode_credentials(): + """Returns the Diode credentials.""" + client_id = get_plugin_config("netbox_diode_plugin", "netbox_to_diode_client_id") + secrets_path = get_plugin_config("netbox_diode_plugin", "secrets_path") + secret_name = get_plugin_config("netbox_diode_plugin", "netbox_to_diode_client_secret_name") + client_secret = get_plugin_config("netbox_diode_plugin", "netbox_to_diode_client_secret") + + if not client_secret: + secret_file = os.path.join(secrets_path, secret_name) + client_secret = _read_secret(secret_file, client_secret) + return client_id, client_secret + +def get_diode_max_auth_retries(): + """Returns the Diode max auth retries.""" + return get_plugin_config("netbox_diode_plugin", "diode_max_auth_retries") + +# Read secret from file +def _read_secret(secret_file: str, default: str | None = None) -> str | None: + try: + f = open(secret_file, encoding='utf-8') + except OSError: + return default + else: + with f: + return f.readline().strip() def get_diode_user(): """Returns the Diode user.""" From 6c1a63d651584add763c087a2662eb06abc6d2ae Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 15 May 2025 08:33:21 -0700 Subject: [PATCH 05/27] OBS-1046 add base for Client Credentials --- netbox_diode_plugin/client.py | 15 ++++-- netbox_diode_plugin/forms.py | 12 +++++ .../diode/client_credential_add.html | 9 ++++ .../diode/client_credential_list.html | 2 +- netbox_diode_plugin/urls.py | 1 + netbox_diode_plugin/views.py | 51 ++++++++++++++++++- 6 files changed, 83 insertions(+), 7 deletions(-) create mode 100644 netbox_diode_plugin/templates/diode/client_credential_add.html diff --git a/netbox_diode_plugin/client.py b/netbox_diode_plugin/client.py index f206224..57ba77e 100644 --- a/netbox_diode_plugin/client.py +++ b/netbox_diode_plugin/client.py @@ -2,17 +2,24 @@ # Copyright 2025 NetBox Labs, Inc. """Diode NetBox Plugin - Client.""" +import logging from netbox_diode_plugin.diode.clients import get_api_client -def create_client(request, client_name, scope): +logger = logging.getLogger("netbox.diode_data") + +def create_client(request, client_name: str, scope: str): + logger.info(f"Creating client {client_name} with scope {scope}") return get_api_client().create_client(client_name, scope) -def delete_client(request, client_id): +def delete_client(request, client_id: str): + logger.info(f"Deleting client {client_id}") return get_api_client().delete_client(client_id) def list_clients(request): + logger.info("Listing clients") response = get_api_client().list_clients() return response["data"] -def get_client(request, client_id): - return get_api_client().get_client(client_id) \ No newline at end of file +def get_client(request, client_id: str): + logger.info(f"Getting client {client_id}") + return get_api_client().get_client(client_id) diff --git a/netbox_diode_plugin/forms.py b/netbox_diode_plugin/forms.py index 5bec310..59e2d25 100644 --- a/netbox_diode_plugin/forms.py +++ b/netbox_diode_plugin/forms.py @@ -4,11 +4,14 @@ from netbox.forms import NetBoxModelForm from netbox.plugins import get_plugin_config from utilities.forms.rendering import FieldSet +from django import forms +from django.utils.translation import gettext_lazy as _ from netbox_diode_plugin.models import Setting __all__ = ( "SettingsForm", + "ClientCredentialForm", ) @@ -40,3 +43,12 @@ def __init__(self, *args, **kwargs): self.fields["diode_target"].help_text = ( "This field is not allowed to be modified." ) + + +class ClientCredentialForm(forms.Form): + """Form for adding client credentials.""" + client_name = forms.CharField( + label=_("Client Name"), + required=True, + widget=forms.TextInput(attrs={"class": "form-control"}), + ) diff --git a/netbox_diode_plugin/templates/diode/client_credential_add.html b/netbox_diode_plugin/templates/diode/client_credential_add.html new file mode 100644 index 0000000..002d7ec --- /dev/null +++ b/netbox_diode_plugin/templates/diode/client_credential_add.html @@ -0,0 +1,9 @@ +{% extends 'generic/object_edit.html' %} +{% load form_helpers %} +{% load i18n %} + +{% block title %}{% trans "Add Client Credential" %}{% endblock %} + +{% block form %} + {% render_form form %} +{% endblock %} \ No newline at end of file diff --git a/netbox_diode_plugin/templates/diode/client_credential_list.html b/netbox_diode_plugin/templates/diode/client_credential_list.html index a10245d..8010982 100644 --- a/netbox_diode_plugin/templates/diode/client_credential_list.html +++ b/netbox_diode_plugin/templates/diode/client_credential_list.html @@ -19,7 +19,7 @@

{% block pagetitle %}{% trans 'Client Credentials' %}{% e
{% block controls %} diff --git a/netbox_diode_plugin/urls.py b/netbox_diode_plugin/urls.py index 8933d41..1bb8cec 100644 --- a/netbox_diode_plugin/urls.py +++ b/netbox_diode_plugin/urls.py @@ -10,5 +10,6 @@ path("settings/", views.SettingsView.as_view(), name="settings"), path("settings/edit/", views.SettingsEditView.as_view(), name="settings_edit"), path("credentials/", views.ClientCredentialListView.as_view(), name="client_credential_list"), + path("credentials/add/", views.ClientCredentialAddView.as_view(), name="client_credential_add"), path("credentials/delete//", views.ClientCredentialDeleteView.as_view(), name="client_credential_delete"), ) diff --git a/netbox_diode_plugin/views.py b/netbox_diode_plugin/views.py index ba7db6c..cb31a43 100644 --- a/netbox_diode_plugin/views.py +++ b/netbox_diode_plugin/views.py @@ -19,10 +19,10 @@ from utilities.forms import ConfirmationForm from utilities.views import register_model_view -from netbox_diode_plugin.forms import SettingsForm +from netbox_diode_plugin.forms import SettingsForm, ClientCredentialForm from netbox_diode_plugin.models import ClientCredentials, Setting from netbox_diode_plugin.tables import ClientCredentialsTable -from netbox_diode_plugin.client import list_clients, get_client, delete_client +from netbox_diode_plugin.client import list_clients, get_client, delete_client, create_client User = get_user_model() @@ -250,3 +250,50 @@ def post(self, request, client_credential_id): "return_url": self.get_return_url(request), }, ) + + +class ClientCredentialAddView(GetReturnURLMixin, BaseDiodeView): + """View for adding client credentials.""" + template_name = "diode/client_credential_add.html" + form_class = ClientCredentialForm + + def get(self, request): + if ret := self.check_authentication(request): + return ret + + form = self.form_class() + return render( + request, + self.template_name, + { + "form": form, + "return_url": self.get_return_url(request), + }, + ) + + def post(self, request): + if ret := self.check_authentication(request): + return ret + + form = self.form_class(request.POST) + if form.is_valid(): + try: + create_client(request, form.cleaned_data["client_name"], "diode-ingest") + messages.success(request, _("Client created successfully")) + return redirect( + reverse( + "plugins:netbox_diode_plugin:client_credential_list", + ) + ) + except Exception as e: + logger.error(f"Error creating client: {str(e)}") + messages.error(request, str(e)) + + return render( + request, + self.template_name, + { + "form": form, + "return_url": self.get_return_url(request), + }, + ) From 5cc34702d0ea228914f9d602ed4afb8e0dd175a0 Mon Sep 17 00:00:00 2001 From: Luke Tucker Date: Thu, 15 May 2025 11:48:27 -0400 Subject: [PATCH 06/27] fix: post json --- netbox_diode_plugin/diode/clients.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox_diode_plugin/diode/clients.py b/netbox_diode_plugin/diode/clients.py index 2efebd6..ec841ef 100644 --- a/netbox_diode_plugin/diode/clients.py +++ b/netbox_diode_plugin/diode/clients.py @@ -79,7 +79,7 @@ def create_client(self, name: str, scope: str) -> dict: "client_name": name, "scope": scope, } - response = requests.post(url, data=data, headers=headers) + response = requests.post(url, json=data, headers=headers) if response.status_code != 200: raise ClientAPIError("Failed to create client", response.status_code) return response.json() From a6ece5805c1d60448ca55aef952ee9d7004d3bbd Mon Sep 17 00:00:00 2001 From: Luke Tucker Date: Thu, 15 May 2025 11:55:06 -0400 Subject: [PATCH 07/27] fix: fixes to client api --- netbox_diode_plugin/diode/clients.py | 8 ++------ netbox_diode_plugin/views.py | 2 +- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/netbox_diode_plugin/diode/clients.py b/netbox_diode_plugin/diode/clients.py index ec841ef..8ed6333 100644 --- a/netbox_diode_plugin/diode/clients.py +++ b/netbox_diode_plugin/diode/clients.py @@ -80,7 +80,7 @@ def create_client(self, name: str, scope: str) -> dict: "scope": scope, } response = requests.post(url, json=data, headers=headers) - if response.status_code != 200: + if response.status_code != 201: raise ClientAPIError("Failed to create client", response.status_code) return response.json() except ClientAPIError as e: @@ -122,9 +122,7 @@ def delete_client(self, client_id: str) -> None: url = self.base_url + f"/clients/{client_id}" headers = {"Authorization": f"Bearer {token}"} response = requests.delete(url, headers=headers) - if response.status_code == 401 or response.status_code == 403: - raise ClientAPIError(f"Failed to delete client {client_id}", response.status_code) - if response.status_code != 200: + if response.status_code != 204: raise ClientAPIError(f"Failed to delete client {client_id}", response.status_code) return response.json() except ClientAPIError as e: @@ -150,8 +148,6 @@ def list_clients(self, page_token: str | None = None, page_size: int | None = No if page_size: params["page_size"] = page_size response = requests.get(url, headers=headers, params=params) - if response.status_code == 401 or response.status_code == 403: - raise ClientAPIError("Failed to get clients", response.status_code) if response.status_code != 200: raise ClientAPIError("Failed to get clients", response.status_code) return response.json() diff --git a/netbox_diode_plugin/views.py b/netbox_diode_plugin/views.py index cb31a43..32feeb2 100644 --- a/netbox_diode_plugin/views.py +++ b/netbox_diode_plugin/views.py @@ -278,7 +278,7 @@ def post(self, request): form = self.form_class(request.POST) if form.is_valid(): try: - create_client(request, form.cleaned_data["client_name"], "diode-ingest") + create_client(request, form.cleaned_data["client_name"], "diode:ingest") messages.success(request, _("Client created successfully")) return redirect( reverse( From bf9c3ac29090c3819bdab296a533cc759577b94a Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 15 May 2025 09:53:48 -0700 Subject: [PATCH 08/27] OBS-1046 display token after add --- netbox_diode_plugin/forms.py | 2 +- .../diode/client_credential_add.html | 11 +++- .../diode/client_credential_secret.html | 60 +++++++++++++++++++ netbox_diode_plugin/urls.py | 1 + netbox_diode_plugin/views.py | 42 ++++++++++++- 5 files changed, 111 insertions(+), 5 deletions(-) create mode 100644 netbox_diode_plugin/templates/diode/client_credential_secret.html diff --git a/netbox_diode_plugin/forms.py b/netbox_diode_plugin/forms.py index 59e2d25..181fb3f 100644 --- a/netbox_diode_plugin/forms.py +++ b/netbox_diode_plugin/forms.py @@ -48,7 +48,7 @@ def __init__(self, *args, **kwargs): class ClientCredentialForm(forms.Form): """Form for adding client credentials.""" client_name = forms.CharField( - label=_("Client Name"), + label=_("Client ID"), required=True, widget=forms.TextInput(attrs={"class": "form-control"}), ) diff --git a/netbox_diode_plugin/templates/diode/client_credential_add.html b/netbox_diode_plugin/templates/diode/client_credential_add.html index 002d7ec..30459f0 100644 --- a/netbox_diode_plugin/templates/diode/client_credential_add.html +++ b/netbox_diode_plugin/templates/diode/client_credential_add.html @@ -6,4 +6,13 @@ {% block form %} {% render_form form %} -{% endblock %} \ No newline at end of file +{% endblock %} + +{% block buttons %} +{% trans "Cancel" %} +
+ +
+{% endblock buttons %} diff --git a/netbox_diode_plugin/templates/diode/client_credential_secret.html b/netbox_diode_plugin/templates/diode/client_credential_secret.html new file mode 100644 index 0000000..fbf4c51 --- /dev/null +++ b/netbox_diode_plugin/templates/diode/client_credential_secret.html @@ -0,0 +1,60 @@ +{% extends 'generic/_base.html' %} +{% load i18n %} +{% load helpers %} + +{% block title %}{% trans "Add a Client Secret" %}{% endblock %} + +{% block tabs %} + +{% endblock tabs %} + +{% block content %} +
+ {% csrf_token %} +
+
+
+
+ +
+
+

{{ client_name }}

+
+
+ +
+
+ +
+
+
+ + +
+ + {% trans "Be sure to record your secret" %} {% trans "prior to leaving this page, as it will no longer be accessible after leaving this page." %} + +
+
+ + +
+
+
+{% endblock %} \ No newline at end of file diff --git a/netbox_diode_plugin/urls.py b/netbox_diode_plugin/urls.py index 1bb8cec..6273f29 100644 --- a/netbox_diode_plugin/urls.py +++ b/netbox_diode_plugin/urls.py @@ -11,5 +11,6 @@ path("settings/edit/", views.SettingsEditView.as_view(), name="settings_edit"), path("credentials/", views.ClientCredentialListView.as_view(), name="client_credential_list"), path("credentials/add/", views.ClientCredentialAddView.as_view(), name="client_credential_add"), + path("credentials/secret/", views.ClientCredentialSecretView.as_view(), name="client_credential_secret"), path("credentials/delete//", views.ClientCredentialDeleteView.as_view(), name="client_credential_delete"), ) diff --git a/netbox_diode_plugin/views.py b/netbox_diode_plugin/views.py index 32feeb2..47ec0c1 100644 --- a/netbox_diode_plugin/views.py +++ b/netbox_diode_plugin/views.py @@ -278,11 +278,13 @@ def post(self, request): form = self.form_class(request.POST) if form.is_valid(): try: - create_client(request, form.cleaned_data["client_name"], "diode:ingest") - messages.success(request, _("Client created successfully")) + response = create_client(request, form.cleaned_data["client_name"], "diode:ingest") + # Store the client secret in session + request.session['client_secret'] = response.get('client_secret') + request.session['client_name'] = form.cleaned_data["client_name"] return redirect( reverse( - "plugins:netbox_diode_plugin:client_credential_list", + "plugins:netbox_diode_plugin:client_credential_secret", ) ) except Exception as e: @@ -297,3 +299,37 @@ def post(self, request): "return_url": self.get_return_url(request), }, ) + + +class ClientCredentialSecretView(BaseDiodeView): + """View for displaying client secret.""" + template_name = "diode/client_credential_secret.html" + + def get(self, request): + if ret := self.check_authentication(request): + return ret + + # Get the client secret from session + client_secret = request.session.get('client_secret') + client_name = request.session.get('client_name') + + if not client_secret: + messages.error(request, _("No client secret found. Please create a new client.")) + return redirect( + reverse( + "plugins:netbox_diode_plugin:client_credential_list", + ) + ) + + # Clear the session data after retrieving it + request.session.pop('client_secret', None) + request.session.pop('client_name', None) + + return render( + request, + self.template_name, + { + "client_secret": client_secret, + "client_name": client_name, + }, + ) From 2f5186a790221e0a3fb844d84bb164195e903d7b Mon Sep 17 00:00:00 2001 From: Luke Tucker Date: Thu, 15 May 2025 14:41:41 -0400 Subject: [PATCH 09/27] add additional detail to README about diode credential --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 564bfd4..0908329 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,11 @@ PLUGINS_CONFIG = { } ``` +If you are running diode locally via the quickstart, the `netbox-to-diode` client_secret may be found in `/path/to/diode/oauth2/client/client-credentials.json`. eg: +``` +echo $(jq -r '.[] | select(.client_id == "netbox-to-diode") | .client_secret' /path/to/diode/oauth2/client/client-credentials.json) +``` + Note: Once you customise usernames with PLUGINS_CONFIG during first installation, you should not change or remove them later on. Doing so will cause the plugin to stop working properly. From 2279a97d30fb6287e6b2844715b46d318ca2fa00 Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 15 May 2025 11:45:44 -0700 Subject: [PATCH 10/27] OBS-1046 delete --- netbox_diode_plugin/client.py | 4 ++++ .../templates/diode/htmx/delete_form.html | 3 +-- netbox_diode_plugin/views.py | 18 ++++++++++-------- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/netbox_diode_plugin/client.py b/netbox_diode_plugin/client.py index 57ba77e..c6bcead 100644 --- a/netbox_diode_plugin/client.py +++ b/netbox_diode_plugin/client.py @@ -7,19 +7,23 @@ logger = logging.getLogger("netbox.diode_data") + def create_client(request, client_name: str, scope: str): logger.info(f"Creating client {client_name} with scope {scope}") return get_api_client().create_client(client_name, scope) + def delete_client(request, client_id: str): logger.info(f"Deleting client {client_id}") return get_api_client().delete_client(client_id) + def list_clients(request): logger.info("Listing clients") response = get_api_client().list_clients() return response["data"] + def get_client(request, client_id: str): logger.info(f"Getting client {client_id}") return get_api_client().get_client(client_id) diff --git a/netbox_diode_plugin/templates/diode/htmx/delete_form.html b/netbox_diode_plugin/templates/diode/htmx/delete_form.html index 3dc798e..60f710a 100644 --- a/netbox_diode_plugin/templates/diode/htmx/delete_form.html +++ b/netbox_diode_plugin/templates/diode/htmx/delete_form.html @@ -3,14 +3,13 @@
{% csrf_token %} + diff --git a/netbox_diode_plugin/views.py b/netbox_diode_plugin/views.py index 47ec0c1..20c2f68 100644 --- a/netbox_diode_plugin/views.py +++ b/netbox_diode_plugin/views.py @@ -218,12 +218,12 @@ def get(self, request, client_credential_id): ) def post(self, request, client_credential_id): + logger.info(f"Deleting client {client_credential_id}") if ret := self.check_authentication(request): return ret - form = ConfirmationForm(initial=request.GET) + form = ConfirmationForm(request.POST) if form.is_valid(): - try: delete_client(request, client_credential_id) messages.success(request, _("Client deleted successfully")) @@ -233,11 +233,11 @@ def post(self, request, client_credential_id): ) messages.error(request, str(e)) - return redirect( - reverse( - "plugins:netbox_diode_plugin:client_credential_list", - ) + return redirect( + reverse( + "plugins:netbox_diode_plugin:client_credential_list", ) + ) data = get_client(request, client_credential_id) return render( @@ -329,7 +329,9 @@ def get(self, request): request, self.template_name, { - "client_secret": client_secret, - "client_name": client_name, + "object": { + "client_name": client_name, + "client_secret": client_secret, + } }, ) From 63ae89705fe94e1ad9dc0203f90fccb46e21a52f Mon Sep 17 00:00:00 2001 From: Luke Tucker Date: Thu, 15 May 2025 14:52:23 -0400 Subject: [PATCH 11/27] fix: no response from delete, don't parse --- netbox_diode_plugin/diode/clients.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox_diode_plugin/diode/clients.py b/netbox_diode_plugin/diode/clients.py index 8ed6333..ecddd66 100644 --- a/netbox_diode_plugin/diode/clients.py +++ b/netbox_diode_plugin/diode/clients.py @@ -124,7 +124,7 @@ def delete_client(self, client_id: str) -> None: response = requests.delete(url, headers=headers) if response.status_code != 204: raise ClientAPIError(f"Failed to delete client {client_id}", response.status_code) - return response.json() + return except ClientAPIError as e: if e.is_auth_error() and attempt < self._max_auth_retries - 1: logger.info(f"Retrying delete_client due to unauthenticated error, attempt {attempt + 1}") From 205fb7a84a9142d33e4aae39b1cc8aa4efc459f0 Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 15 May 2025 13:43:40 -0700 Subject: [PATCH 12/27] OBS-1046 ui fixes --- netbox_diode_plugin/forms.py | 2 +- netbox_diode_plugin/tables.py | 16 ++++++++++++++- .../diode/client_credential_secret.html | 20 +++++++++++++++++-- netbox_diode_plugin/views.py | 13 +++++++++--- 4 files changed, 44 insertions(+), 7 deletions(-) diff --git a/netbox_diode_plugin/forms.py b/netbox_diode_plugin/forms.py index 181fb3f..59e2d25 100644 --- a/netbox_diode_plugin/forms.py +++ b/netbox_diode_plugin/forms.py @@ -48,7 +48,7 @@ def __init__(self, *args, **kwargs): class ClientCredentialForm(forms.Form): """Form for adding client credentials.""" client_name = forms.CharField( - label=_("Client ID"), + label=_("Client Name"), required=True, widget=forms.TextInput(attrs={"class": "form-control"}), ) diff --git a/netbox_diode_plugin/tables.py b/netbox_diode_plugin/tables.py index 6ee0014..8a3d0dd 100644 --- a/netbox_diode_plugin/tables.py +++ b/netbox_diode_plugin/tables.py @@ -6,22 +6,31 @@ import django_tables2 as tables from django.urls import reverse from django.utils.safestring import mark_safe +from django.utils.dateparse import parse_datetime from django.utils.translation import gettext_lazy as _ from netbox.tables import BaseTable, columns class ClientCredentialsTable(BaseTable): label = tables.Column( - verbose_name=_("Label"), + verbose_name=_("Name"), accessor="client_name", + orderable=False, ) client_id = tables.Column( verbose_name=_("Client ID"), accessor="client_id", + orderable=False, + ) + created_at = columns.DateTimeColumn( + verbose_name=_("Created"), + accessor="created_at", + orderable=False, ) client_secret = tables.Column( verbose_name=_("Client Secret"), empty_values=(), + orderable=False, ) actions = tables.Column( verbose_name=_(""), @@ -46,6 +55,7 @@ class Meta: default_columns = ( "label", "client_id", + "created_at", "client_secret", "actions", ) @@ -56,6 +66,10 @@ class Meta: def render_client_secret(self, value): return "*****" + def render_created_at(self, value): + if value: + return parse_datetime(value) + return "-" def render_actions(self, record): delete_url = reverse( diff --git a/netbox_diode_plugin/templates/diode/client_credential_secret.html b/netbox_diode_plugin/templates/diode/client_credential_secret.html index fbf4c51..a6be106 100644 --- a/netbox_diode_plugin/templates/diode/client_credential_secret.html +++ b/netbox_diode_plugin/templates/diode/client_credential_secret.html @@ -17,6 +17,17 @@ {% csrf_token %}
+
+
+ +
+
+

{{ object.client_name }}

+
+
+
-

{{ client_name }}

+
+ + +
@@ -36,7 +52,7 @@
- + diff --git a/netbox_diode_plugin/views.py b/netbox_diode_plugin/views.py index 20c2f68..a07def1 100644 --- a/netbox_diode_plugin/views.py +++ b/netbox_diode_plugin/views.py @@ -187,12 +187,14 @@ def get(self, request): { "model": ClientCredentials, "table": table, + "total_count": len(data), }, ) context = { "model": ClientCredentials, "table": table, + "total_count": len(data), } return render(request, self.template_name, context) @@ -256,6 +258,7 @@ class ClientCredentialAddView(GetReturnURLMixin, BaseDiodeView): """View for adding client credentials.""" template_name = "diode/client_credential_add.html" form_class = ClientCredentialForm + default_return_url = "plugins:netbox_diode_plugin:client_credential_list" def get(self, request): if ret := self.check_authentication(request): @@ -267,7 +270,7 @@ def get(self, request): self.template_name, { "form": form, - "return_url": self.get_return_url(request), + "return_url": self.get_return_url(request) or reverse(self.default_return_url), }, ) @@ -279,9 +282,10 @@ def post(self, request): if form.is_valid(): try: response = create_client(request, form.cleaned_data["client_name"], "diode:ingest") - # Store the client secret in session + # Store the client credentials in session request.session['client_secret'] = response.get('client_secret') request.session['client_name'] = form.cleaned_data["client_name"] + request.session['client_id'] = response.get('client_id') return redirect( reverse( "plugins:netbox_diode_plugin:client_credential_secret", @@ -296,7 +300,7 @@ def post(self, request): self.template_name, { "form": form, - "return_url": self.get_return_url(request), + "return_url": self.get_return_url(request) or reverse(self.default_return_url), }, ) @@ -312,6 +316,7 @@ def get(self, request): # Get the client secret from session client_secret = request.session.get('client_secret') client_name = request.session.get('client_name') + client_id = request.session.get('client_id') if not client_secret: messages.error(request, _("No client secret found. Please create a new client.")) @@ -324,6 +329,7 @@ def get(self, request): # Clear the session data after retrieving it request.session.pop('client_secret', None) request.session.pop('client_name', None) + request.session.pop('client_id', None) return render( request, @@ -331,6 +337,7 @@ def get(self, request): { "object": { "client_name": client_name, + "client_id": client_id, "client_secret": client_secret, } }, From 2e94b2946e35c7e2ebf75445547a03621156beb0 Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 15 May 2025 13:50:03 -0700 Subject: [PATCH 13/27] OBS-1046 ui fixes --- netbox_diode_plugin/forms.py | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox_diode_plugin/forms.py b/netbox_diode_plugin/forms.py index 59e2d25..d8bf62e 100644 --- a/netbox_diode_plugin/forms.py +++ b/netbox_diode_plugin/forms.py @@ -50,5 +50,6 @@ class ClientCredentialForm(forms.Form): client_name = forms.CharField( label=_("Client Name"), required=True, + help_text=_("Create a unique Client ID/Client Secret pair as client authentication credentials for the Diode ingestion service."), widget=forms.TextInput(attrs={"class": "form-control"}), ) From 9f88f6c07fb34b98572a768b39046242a80ba598 Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 15 May 2025 14:30:42 -0700 Subject: [PATCH 14/27] OBS-1046 ui fixes --- netbox_diode_plugin/forms.py | 2 +- .../templates/diode/client_credential_secret.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox_diode_plugin/forms.py b/netbox_diode_plugin/forms.py index d8bf62e..4a434ba 100644 --- a/netbox_diode_plugin/forms.py +++ b/netbox_diode_plugin/forms.py @@ -50,6 +50,6 @@ class ClientCredentialForm(forms.Form): client_name = forms.CharField( label=_("Client Name"), required=True, - help_text=_("Create a unique Client ID/Client Secret pair as client authentication credentials for the Diode ingestion service."), + help_text=_("Enter a name for the client credential that will be created for authentication to the Diode ingestion service."), widget=forms.TextInput(attrs={"class": "form-control"}), ) diff --git a/netbox_diode_plugin/templates/diode/client_credential_secret.html b/netbox_diode_plugin/templates/diode/client_credential_secret.html index a6be106..28f688c 100644 --- a/netbox_diode_plugin/templates/diode/client_credential_secret.html +++ b/netbox_diode_plugin/templates/diode/client_credential_secret.html @@ -2,7 +2,7 @@ {% load i18n %} {% load helpers %} -{% block title %}{% trans "Add a Client Secret" %}{% endblock %} +{% block title %}{% trans "Add Client Credential" %}{% endblock %} {% block tabs %}
- -
@@ -52,8 +52,8 @@
- -
From d86815f2430b768108417c877fde9ff77803cd11 Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 15 May 2025 14:48:46 -0700 Subject: [PATCH 16/27] OBS-1046 fix delete cancel --- netbox_diode_plugin/views.py | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/netbox_diode_plugin/views.py b/netbox_diode_plugin/views.py index a07def1..898a698 100644 --- a/netbox_diode_plugin/views.py +++ b/netbox_diode_plugin/views.py @@ -202,6 +202,7 @@ def get(self, request): class ClientCredentialDeleteView(GetReturnURLMixin, BaseDiodeView): template_name = "diode/client_credential_delete.html" + default_return_url = "plugins:netbox_diode_plugin:client_credential_list" def get(self, request, client_credential_id): if ret := self.check_authentication(request): @@ -215,7 +216,7 @@ def get(self, request, client_credential_id): { "object": data, "object_type": "Client Credential", - "return_url": self.get_return_url(request), + "return_url": self.get_return_url(request) or reverse(self.default_return_url), }, ) @@ -241,18 +242,6 @@ def post(self, request, client_credential_id): ) ) - data = get_client(request, client_credential_id) - return render( - request, - self.template_name, - { - "object": data, - "object_type": "Client Credential", - "form": form, - "return_url": self.get_return_url(request), - }, - ) - class ClientCredentialAddView(GetReturnURLMixin, BaseDiodeView): """View for adding client credentials.""" From 69e60a1518d27dd1bfb8fb7da2fb985b3e3225ce Mon Sep 17 00:00:00 2001 From: Luke Tucker Date: Thu, 15 May 2025 22:24:13 -0400 Subject: [PATCH 17/27] fix: validate client id before requesting --- netbox_diode_plugin/diode/clients.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/netbox_diode_plugin/diode/clients.py b/netbox_diode_plugin/diode/clients.py index ecddd66..40249c6 100644 --- a/netbox_diode_plugin/diode/clients.py +++ b/netbox_diode_plugin/diode/clients.py @@ -9,6 +9,7 @@ import requests import threading from urllib.parse import urlencode +import re from netbox_diode_plugin.plugin_config import ( get_diode_auth_base_url, get_diode_credentials, @@ -20,6 +21,7 @@ logger = logging.getLogger("netbox.diode_data") +valid_client_id_re = re.compile(r"^[a-zA-Z0-9_-]{1,64}$") _client = None _client_lock = threading.Lock() @@ -93,6 +95,9 @@ def create_client(self, name: str, scope: str) -> dict: def get_client(self, client_id: str) -> dict: """Get a client.""" + if not valid_client_id_re.match(client_id): + raise ClientAPIError(f"Invalid client ID: {client_id}") + for attempt in range(self._max_auth_retries): token = None try: @@ -115,6 +120,9 @@ def get_client(self, client_id: str) -> dict: def delete_client(self, client_id: str) -> None: """Delete a client.""" + if not valid_client_id_re.match(client_id): + raise ClientAPIError(f"Invalid client ID: {client_id}") + for attempt in range(self._max_auth_retries): token = None try: From 1724ae6305b2d397483c43a035ccbd3a73caedbd Mon Sep 17 00:00:00 2001 From: Luke Tucker Date: Thu, 15 May 2025 23:03:00 -0400 Subject: [PATCH 18/27] add unit tests for diode clients api wrapper --- netbox_diode_plugin/diode/clients.py | 4 +- .../tests/test_diode_clients.py | 226 ++++++++++++++++++ 2 files changed, 228 insertions(+), 2 deletions(-) create mode 100644 netbox_diode_plugin/tests/test_diode_clients.py diff --git a/netbox_diode_plugin/diode/clients.py b/netbox_diode_plugin/diode/clients.py index 40249c6..43a8d20 100644 --- a/netbox_diode_plugin/diode/clients.py +++ b/netbox_diode_plugin/diode/clients.py @@ -96,7 +96,7 @@ def create_client(self, name: str, scope: str) -> dict: def get_client(self, client_id: str) -> dict: """Get a client.""" if not valid_client_id_re.match(client_id): - raise ClientAPIError(f"Invalid client ID: {client_id}") + raise ValueError(f"Invalid client ID: {client_id}") for attempt in range(self._max_auth_retries): token = None @@ -121,7 +121,7 @@ def get_client(self, client_id: str) -> dict: def delete_client(self, client_id: str) -> None: """Delete a client.""" if not valid_client_id_re.match(client_id): - raise ClientAPIError(f"Invalid client ID: {client_id}") + raise ValueError(f"Invalid client ID: {client_id}") for attempt in range(self._max_auth_retries): token = None diff --git a/netbox_diode_plugin/tests/test_diode_clients.py b/netbox_diode_plugin/tests/test_diode_clients.py new file mode 100644 index 0000000..c8c1eac --- /dev/null +++ b/netbox_diode_plugin/tests/test_diode_clients.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python +# Copyright 2025 NetBox Labs, Inc. +"""Diode NetBox Plugin - Diode Clients API Tests.""" + +from unittest import mock + +from django.test import TestCase + +from netbox_diode_plugin.diode.clients import ClientAPI, ClientAPIError + +class DiodeClientsTestCase(TestCase): + """Test cases for Diode Clients API.""" + + def test_create_client(self): + """Test creating a client.""" + with mock.patch('requests.post') as mock_post: + client = ClientAPI( + base_url="http://test-diode-url", + client_id="test-client-id", + client_secret="test-client-secret" + ) + client._client_auth_token = "test-client-auth-token" + + mock_post.return_value.status_code = 201 + mock_post.return_value.json.return_value = { + "client_id": "test-client-id", + "client_secret": "test-client-secret", + "client_name": "test-client", + "scope": "test-scope" + } + + created = client.create_client( + name="test-client", + scope="test-scope" + ) + + self.assertEqual(created, { + "client_id": "test-client-id", + "client_secret": "test-client-secret", + "client_name": "test-client", + "scope": "test-scope" + }) + + mock_post.assert_called_once_with( + "http://test-diode-url/clients", + headers={ + "Authorization": "Bearer test-client-auth-token" + }, + json={ + "client_name": "test-client", + "scope": "test-scope" + } + ) + + def test_list_clients(self): + """Test listing clients.""" + with mock.patch('requests.get') as mock_get: + client = ClientAPI( + base_url="http://test-diode-url", + client_id="test-client-id", + client_secret="test-client-secret" + ) + client._client_auth_token = "test-client-auth-token" + + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = { + "data": [ + { + "client_id": "test-client-id", + "client_name": "test-client", + "scope": "test-scope" + } + ], + "next_page_token": "test-next-page-token", + "prev_page_token": "test-prev-page-token" + } + + result = client.list_clients(page_size=100) + + self.assertEqual(result["data"], [ + { + "client_id": "test-client-id", + "client_name": "test-client", + "scope": "test-scope" + } + ]) + + self.assertEqual(result["next_page_token"], "test-next-page-token") + self.assertEqual(result["prev_page_token"], "test-prev-page-token") + + mock_get.assert_called_once_with( + "http://test-diode-url/clients", + headers={ + "Authorization": "Bearer test-client-auth-token" + }, + params={ + "page_size": 100, + } + ) + + def test_get_client(self): + """Test getting a client.""" + with mock.patch('requests.get') as mock_get: + client = ClientAPI( + base_url="http://test-diode-url", + client_id="test-client-id", + client_secret="test-client-secret" + ) + client._client_auth_token = "test-client-auth-token" + + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = { + "client_id": "test-client-id", + "client_name": "test-client", + "scope": "test-scope" + } + + result = client.get_client("test-client-id") + + self.assertEqual(result, { + "client_id": "test-client-id", + "client_name": "test-client", + "scope": "test-scope" + }) + + mock_get.assert_called_once_with( + "http://test-diode-url/clients/test-client-id", + headers={ + "Authorization": "Bearer test-client-auth-token" + } + ) + + def test_get_client_raises_error_on_bad_id(self): + """Test getting a client raises an error on bad ID.""" + client = ClientAPI( + base_url="http://test-diode-url", + client_id="test-client-id", + client_secret="test-client-secret" + ) + with self.assertRaises(ValueError): + client.get_client("../bad/../client/id") + + def test_delete_client(self): + """Test deleting a client.""" + with mock.patch('requests.delete') as mock_delete: + client = ClientAPI( + base_url="http://test-diode-url", + client_id="test-client-id", + client_secret="test-client-secret" + ) + client._client_auth_token = "test-client-auth-token" + + mock_delete.return_value.status_code = 204 + mock_delete.return_value.raise_for_status = mock.Mock() + + client.delete_client("test-client-id") + + mock_delete.assert_called_once_with( + "http://test-diode-url/clients/test-client-id", + headers={ + "Authorization": "Bearer test-client-auth-token" + } + ) + + def test_delete_client_raises_error_on_bad_id(self): + """Test deleting a client raises an error on bad ID.""" + client = ClientAPI( + base_url="http://test-diode-url", + client_id="test-client-id", + client_secret="test-client-secret" + ) + with self.assertRaises(ValueError): + client.delete_client("../bad/../client/id") + + def test_authentication_retries(self): + """Test authentication retries.""" + with mock.patch('requests.post') as mock_post: + client = ClientAPI( + base_url="http://test-diode-url", + client_id="test-client-id", + client_secret="test-client-secret" + ) + client._client_auth_token = "test-client-auth-token" + + mock_post.side_effect = [ + ClientAPIError("Failed to create client", 401), + mock.Mock(status_code=200, json=lambda: {"access_token": "new-access-token"}), + mock.Mock(status_code=201, json=lambda: {"client_id": "test-client-id", "client_secret": "test-client-secret", "client_name": "test-client", "scope": "diode:read diode:write"}), + ] + + result = client.create_client("test-client", "diode:read diode:write") + self.assertEqual(result, { + "client_id": "test-client-id", + "client_secret": "test-client-secret", + "client_name": "test-client", + "scope": "diode:read diode:write" + }) + + self.assertEqual(mock_post.call_count, 3) + + mock_post.assert_has_calls([ + mock.call("http://test-diode-url/clients", + headers={ + "Authorization": "Bearer test-client-auth-token" + }, + json={ + "client_name": "test-client", + "scope": "diode:read diode:write", + } + ), + mock.call("http://test-diode-url/token", + data='grant_type=client_credentials&client_id=test-client-id&client_secret=test-client-secret&scope=diode%3Aread+diode%3Awrite', + headers={'Content-Type': 'application/x-www-form-urlencoded'} + ), + mock.call("http://test-diode-url/clients", + headers={ + "Authorization": "Bearer new-access-token" + }, + json={ + "client_name": "test-client", + "scope": "diode:read diode:write", + } + ), + ]) + + From ce7e9828831c7e8651f7c0f40d340e06db01594b Mon Sep 17 00:00:00 2001 From: Luke Tucker <64618+ltucker@users.noreply.github.com> Date: Thu, 15 May 2025 23:05:29 -0400 Subject: [PATCH 19/27] Potential fix for code scanning alert no. 23: Log Injection Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- netbox_diode_plugin/client.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox_diode_plugin/client.py b/netbox_diode_plugin/client.py index c6bcead..75da2e4 100644 --- a/netbox_diode_plugin/client.py +++ b/netbox_diode_plugin/client.py @@ -14,7 +14,8 @@ def create_client(request, client_name: str, scope: str): def delete_client(request, client_id: str): - logger.info(f"Deleting client {client_id}") + sanitized_client_id = client_id.replace("\n", "").replace("\r", "") + logger.info(f"Deleting client {sanitized_client_id}") return get_api_client().delete_client(client_id) From d309a0cad724414b8e151ddf5542af8007428544 Mon Sep 17 00:00:00 2001 From: Luke Tucker <64618+ltucker@users.noreply.github.com> Date: Thu, 15 May 2025 23:06:34 -0400 Subject: [PATCH 20/27] Potential fix for code scanning alert no. 25: Log Injection Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- netbox_diode_plugin/views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox_diode_plugin/views.py b/netbox_diode_plugin/views.py index 898a698..01e8a9b 100644 --- a/netbox_diode_plugin/views.py +++ b/netbox_diode_plugin/views.py @@ -221,7 +221,8 @@ def get(self, request, client_credential_id): ) def post(self, request, client_credential_id): - logger.info(f"Deleting client {client_credential_id}") + sanitized_client_credential_id = client_credential_id.replace('\n', '').replace('\r', '') + logger.info(f"Deleting client {sanitized_client_credential_id}") if ret := self.check_authentication(request): return ret From 9656e9b7b4ece3685ed7ba4d0fb25e1c98b4c51c Mon Sep 17 00:00:00 2001 From: Luke Tucker <64618+ltucker@users.noreply.github.com> Date: Thu, 15 May 2025 23:07:25 -0400 Subject: [PATCH 21/27] Potential fix for code scanning alert no. 24: Log Injection Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- netbox_diode_plugin/client.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox_diode_plugin/client.py b/netbox_diode_plugin/client.py index 75da2e4..59e22ca 100644 --- a/netbox_diode_plugin/client.py +++ b/netbox_diode_plugin/client.py @@ -26,5 +26,6 @@ def list_clients(request): def get_client(request, client_id: str): - logger.info(f"Getting client {client_id}") + sanitized_client_id = client_id.replace("\n", "").replace("\r", "") + logger.info(f"Getting client {sanitized_client_id}") return get_api_client().get_client(client_id) From e6e17318445b3cb75973b1151b4d3b9d5a8cc83c Mon Sep 17 00:00:00 2001 From: Luke Tucker <64618+ltucker@users.noreply.github.com> Date: Thu, 15 May 2025 23:09:09 -0400 Subject: [PATCH 22/27] Potential fix for code scanning alert no. 22: URL redirection from remote source Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- netbox_diode_plugin/views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox_diode_plugin/views.py b/netbox_diode_plugin/views.py index 01e8a9b..da837a5 100644 --- a/netbox_diode_plugin/views.py +++ b/netbox_diode_plugin/views.py @@ -145,7 +145,8 @@ def check_authentication(self, request): if not url_has_allowed_host_and_scheme(next_url, allowed_hosts=None): next_url = "/" - return redirect(f"{netbox_settings.LOGIN_URL}?next={next_url}") + safe_redirect_url = f"{netbox_settings.LOGIN_URL}?next={next_url}" + return redirect(safe_redirect_url) def get_required_permission(self): return get_permission_for_model(self.model, "view") From 9e0af56b6a66921ee52bd4b75943b4f1d204f257 Mon Sep 17 00:00:00 2001 From: Luke Tucker <64618+ltucker@users.noreply.github.com> Date: Thu, 15 May 2025 23:12:23 -0400 Subject: [PATCH 23/27] Potential fix for code scanning alert no. 26: Log Injection Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- netbox_diode_plugin/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox_diode_plugin/views.py b/netbox_diode_plugin/views.py index da837a5..673b179 100644 --- a/netbox_diode_plugin/views.py +++ b/netbox_diode_plugin/views.py @@ -234,7 +234,7 @@ def post(self, request, client_credential_id): messages.success(request, _("Client deleted successfully")) except Exception as e: logger.error( - f"Error deleting client: {client_credential_id} error: {str(e)}" + f"Error deleting client: {sanitized_client_credential_id} error: {str(e)}" ) messages.error(request, str(e)) From 4df70630eb5996e0c4ecd5cb44110bf9ddc56373 Mon Sep 17 00:00:00 2001 From: Luke Tucker Date: Thu, 15 May 2025 23:18:05 -0400 Subject: [PATCH 24/27] linting --- netbox_diode_plugin/client.py | 5 ++++ netbox_diode_plugin/diode/clients.py | 8 ++++-- netbox_diode_plugin/forms.py | 5 ++-- netbox_diode_plugin/models.py | 4 +-- netbox_diode_plugin/tables.py | 21 +++++++++----- .../tests/test_diode_clients.py | 8 +++++- netbox_diode_plugin/views.py | 28 +++++++++++++++---- 7 files changed, 58 insertions(+), 21 deletions(-) diff --git a/netbox_diode_plugin/client.py b/netbox_diode_plugin/client.py index 59e22ca..5d9e785 100644 --- a/netbox_diode_plugin/client.py +++ b/netbox_diode_plugin/client.py @@ -3,29 +3,34 @@ """Diode NetBox Plugin - Client.""" import logging + from netbox_diode_plugin.diode.clients import get_api_client logger = logging.getLogger("netbox.diode_data") def create_client(request, client_name: str, scope: str): + """Create client.""" logger.info(f"Creating client {client_name} with scope {scope}") return get_api_client().create_client(client_name, scope) def delete_client(request, client_id: str): + """Delete client.""" sanitized_client_id = client_id.replace("\n", "").replace("\r", "") logger.info(f"Deleting client {sanitized_client_id}") return get_api_client().delete_client(client_id) def list_clients(request): + """List clients.""" logger.info("Listing clients") response = get_api_client().list_clients() return response["data"] def get_client(request, client_id: str): + """Get client.""" sanitized_client_id = client_id.replace("\n", "").replace("\r", "") logger.info(f"Getting client {sanitized_client_id}") return get_api_client().get_client(client_id) diff --git a/netbox_diode_plugin/diode/clients.py b/netbox_diode_plugin/diode/clients.py index 43a8d20..fde7732 100644 --- a/netbox_diode_plugin/diode/clients.py +++ b/netbox_diode_plugin/diode/clients.py @@ -2,14 +2,16 @@ # Copyright 2025 NetBox Labs Inc """Diode NetBox Plugin - Diode - Auth.""" -from dataclasses import dataclass import datetime import json import logging -import requests +import re import threading +from dataclasses import dataclass from urllib.parse import urlencode -import re + +import requests + from netbox_diode_plugin.plugin_config import ( get_diode_auth_base_url, get_diode_credentials, diff --git a/netbox_diode_plugin/forms.py b/netbox_diode_plugin/forms.py index 4a434ba..05de3bd 100644 --- a/netbox_diode_plugin/forms.py +++ b/netbox_diode_plugin/forms.py @@ -1,11 +1,11 @@ # !/usr/bin/env python # Copyright 2025 NetBox Labs, Inc. """Diode NetBox Plugin - Forms.""" +from django import forms +from django.utils.translation import gettext_lazy as _ from netbox.forms import NetBoxModelForm from netbox.plugins import get_plugin_config from utilities.forms.rendering import FieldSet -from django import forms -from django.utils.translation import gettext_lazy as _ from netbox_diode_plugin.models import Setting @@ -47,6 +47,7 @@ def __init__(self, *args, **kwargs): class ClientCredentialForm(forms.Form): """Form for adding client credentials.""" + client_name = forms.CharField( label=_("Client Name"), required=True, diff --git a/netbox_diode_plugin/models.py b/netbox_diode_plugin/models.py index ea4d223..ed9e6c7 100644 --- a/netbox_diode_plugin/models.py +++ b/netbox_diode_plugin/models.py @@ -41,9 +41,7 @@ def get_absolute_url(self): class ClientCredentials(models.Model): - """ - Dummy model to allow for permissions, saved filters, etc.. - """ + """Dummy model to allow for permissions, saved filters, etc..""" class Meta: """Meta class.""" diff --git a/netbox_diode_plugin/tables.py b/netbox_diode_plugin/tables.py index 8a3d0dd..8e69d6a 100644 --- a/netbox_diode_plugin/tables.py +++ b/netbox_diode_plugin/tables.py @@ -5,13 +5,15 @@ import django_tables2 as tables from django.urls import reverse -from django.utils.safestring import mark_safe from django.utils.dateparse import parse_datetime +from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ from netbox.tables import BaseTable, columns class ClientCredentialsTable(BaseTable): + """Client credentials table.""" + label = tables.Column( verbose_name=_("Name"), accessor="client_name", @@ -47,6 +49,8 @@ class ClientCredentialsTable(BaseTable): embedded = False class Meta: + """Meta class.""" + attrs = { "class": "table table-hover object-list", "td": {"class": "align-middle"}, @@ -64,26 +68,29 @@ class Meta: footer = False def render_client_secret(self, value): + """Render client secret.""" return "*****" def render_created_at(self, value): + """Render created at.""" if value: return parse_datetime(value) return "-" def render_actions(self, record): + """Render actions.""" delete_url = reverse( "plugins:netbox_diode_plugin:client_credential_delete", kwargs={"client_credential_id": record["client_id"]}, ) buttons = f""" - diff --git a/netbox_diode_plugin/tests/test_diode_clients.py b/netbox_diode_plugin/tests/test_diode_clients.py index c8c1eac..993d752 100644 --- a/netbox_diode_plugin/tests/test_diode_clients.py +++ b/netbox_diode_plugin/tests/test_diode_clients.py @@ -8,6 +8,7 @@ from netbox_diode_plugin.diode.clients import ClientAPI, ClientAPIError + class DiodeClientsTestCase(TestCase): """Test cases for Diode Clients API.""" @@ -185,7 +186,12 @@ def test_authentication_retries(self): mock_post.side_effect = [ ClientAPIError("Failed to create client", 401), mock.Mock(status_code=200, json=lambda: {"access_token": "new-access-token"}), - mock.Mock(status_code=201, json=lambda: {"client_id": "test-client-id", "client_secret": "test-client-secret", "client_name": "test-client", "scope": "diode:read diode:write"}), + mock.Mock(status_code=201, json=lambda: { + "client_id": "test-client-id", + "client_secret": "test-client-secret", + "client_name": "test-client", + "scope": "diode:read diode:write" + }), ] result = client.create_client("test-client", "diode:read diode:write") diff --git a/netbox_diode_plugin/views.py b/netbox_diode_plugin/views.py index 673b179..7367df0 100644 --- a/netbox_diode_plugin/views.py +++ b/netbox_diode_plugin/views.py @@ -9,20 +9,20 @@ from django.http import HttpResponseRedirect from django.shortcuts import redirect, render from django.urls import reverse -from django.utils.translation import gettext as _ from django.utils.http import url_has_allowed_host_and_scheme +from django.utils.translation import gettext as _ from django.views.generic import View from netbox.plugins import get_plugin_config from netbox.views import generic +from utilities.forms import ConfirmationForm from utilities.htmx import htmx_partial from utilities.permissions import get_permission_for_model -from utilities.forms import ConfirmationForm from utilities.views import register_model_view -from netbox_diode_plugin.forms import SettingsForm, ClientCredentialForm +from netbox_diode_plugin.client import create_client, delete_client, get_client, list_clients +from netbox_diode_plugin.forms import ClientCredentialForm, SettingsForm from netbox_diode_plugin.models import ClientCredentials, Setting from netbox_diode_plugin.tables import ClientCredentialsTable -from netbox_diode_plugin.client import list_clients, get_client, delete_client, create_client User = get_user_model() @@ -123,9 +123,10 @@ def post(self, request, *args, **kwargs): class GetReturnURLMixin: + """Get return URL mixin.""" def get_return_url(self, request): - + """Get return URL.""" # First, see if `return_url` was specified as a query parameter or form data. Use this URL only if it's # considered safe. return_url = request.GET.get("return_url") or request.POST.get("return_url") @@ -138,8 +139,10 @@ def get_return_url(self, request): class BaseDiodeView(View): + """Base diode view.""" def check_authentication(self, request): + """Check authentication.""" if not request.user.is_authenticated or not request.user.is_staff: next_url = request.path if not url_has_allowed_host_and_scheme(next_url, allowed_hosts=None): @@ -147,16 +150,21 @@ def check_authentication(self, request): safe_redirect_url = f"{netbox_settings.LOGIN_URL}?next={next_url}" return redirect(safe_redirect_url) + return None def get_required_permission(self): + """Get required permission.""" return get_permission_for_model(self.model, "view") class ClientCredentialListView(BaseDiodeView): + """Client credential list view.""" + table = ClientCredentialsTable template_name = "diode/client_credential_list.html" model = ClientCredentials def get_table_data(self, request): + """Get table data.""" try: data = list_clients(request) total = len(data) @@ -169,6 +177,7 @@ def get_table_data(self, request): return total, data def get(self, request): + """GET request handler.""" if ret := self.check_authentication(request): return ret @@ -202,10 +211,13 @@ def get(self, request): class ClientCredentialDeleteView(GetReturnURLMixin, BaseDiodeView): + """Client credential delete view.""" + template_name = "diode/client_credential_delete.html" default_return_url = "plugins:netbox_diode_plugin:client_credential_list" def get(self, request, client_credential_id): + """GET request handler.""" if ret := self.check_authentication(request): return ret @@ -222,6 +234,7 @@ def get(self, request, client_credential_id): ) def post(self, request, client_credential_id): + """POST request handler.""" sanitized_client_credential_id = client_credential_id.replace('\n', '').replace('\r', '') logger.info(f"Deleting client {sanitized_client_credential_id}") if ret := self.check_authentication(request): @@ -247,11 +260,13 @@ def post(self, request, client_credential_id): class ClientCredentialAddView(GetReturnURLMixin, BaseDiodeView): """View for adding client credentials.""" + template_name = "diode/client_credential_add.html" form_class = ClientCredentialForm default_return_url = "plugins:netbox_diode_plugin:client_credential_list" def get(self, request): + """GET request handler.""" if ret := self.check_authentication(request): return ret @@ -266,6 +281,7 @@ def get(self, request): ) def post(self, request): + """POST request handler.""" if ret := self.check_authentication(request): return ret @@ -298,9 +314,11 @@ def post(self, request): class ClientCredentialSecretView(BaseDiodeView): """View for displaying client secret.""" + template_name = "diode/client_credential_secret.html" def get(self, request): + """Get request handler.""" if ret := self.check_authentication(request): return ret From fee932419c0f1231f24cb6b314c7db1095b6ab46 Mon Sep 17 00:00:00 2001 From: Luke Tucker Date: Fri, 16 May 2025 09:50:02 -0400 Subject: [PATCH 25/27] display specific message if no diode credential is configured --- netbox_diode_plugin/diode/clients.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/netbox_diode_plugin/diode/clients.py b/netbox_diode_plugin/diode/clients.py index fde7732..32a3670 100644 --- a/netbox_diode_plugin/diode/clients.py +++ b/netbox_diode_plugin/diode/clients.py @@ -35,6 +35,12 @@ def get_api_client(): with _client_lock: if _client is None: client_id, client_secret = get_diode_credentials() + if not client_id: + raise ClientAPIError( + "Please update the plugin configuration to access this feature.\nMissing netbox to diode client id.", 500) + if not client_secret: + raise ClientAPIError( + "Please update the plugin configuration to access this feature.\nMissing netbox to diode client secret.", 500) max_auth_retries = get_diode_max_auth_retries() _client = ClientAPI( base_url=get_diode_auth_base_url(), From 8700cfd0d54dab80f7163cc038ace0872bffd589 Mon Sep 17 00:00:00 2001 From: Luke Tucker <64618+ltucker@users.noreply.github.com> Date: Fri, 16 May 2025 10:41:29 -0400 Subject: [PATCH 26/27] Update netbox_diode_plugin/views.py Co-authored-by: Michal Fiedorowicz --- netbox_diode_plugin/views.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/netbox_diode_plugin/views.py b/netbox_diode_plugin/views.py index 7367df0..1143c5d 100644 --- a/netbox_diode_plugin/views.py +++ b/netbox_diode_plugin/views.py @@ -144,12 +144,7 @@ class BaseDiodeView(View): def check_authentication(self, request): """Check authentication.""" if not request.user.is_authenticated or not request.user.is_staff: - next_url = request.path - if not url_has_allowed_host_and_scheme(next_url, allowed_hosts=None): - next_url = "/" - - safe_redirect_url = f"{netbox_settings.LOGIN_URL}?next={next_url}" - return redirect(safe_redirect_url) + return redirect_to_login(request) return None def get_required_permission(self): From 7c07bfe8cc2844e512f886be02c704802b2e8190 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 16 May 2025 08:26:11 -0700 Subject: [PATCH 27/27] OBS-1046 update message --- .../templates/diode/client_credential_secret.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox_diode_plugin/templates/diode/client_credential_secret.html b/netbox_diode_plugin/templates/diode/client_credential_secret.html index 85e1101..74f43f3 100644 --- a/netbox_diode_plugin/templates/diode/client_credential_secret.html +++ b/netbox_diode_plugin/templates/diode/client_credential_secret.html @@ -58,7 +58,7 @@
- {% trans "Be sure to record your secret" %} {% trans "prior to leaving this page, as it will no longer be accessible after leaving this page." %} + {% trans "You can only view your secret once. Be sure to save it before leaving." %}