Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
69d0ae7
OBS-1046 add base for Client Credentials
arthanson May 9, 2025
2a0bc62
OBS-1046 add base for Client Credentials
arthanson May 9, 2025
f1fb759
OBS-1046 add base for Client Credentials
arthanson May 13, 2025
c427d24
feat: add api client for diode auth credentials api
ltucker May 15, 2025
537f45a
feat: add api client for diode auth credentials api (#104)
arthanson May 15, 2025
6c1a63d
OBS-1046 add base for Client Credentials
arthanson May 15, 2025
5cc3470
fix: post json
ltucker May 15, 2025
a6ece58
fix: fixes to client api
ltucker May 15, 2025
bf9c3ac
OBS-1046 display token after add
arthanson May 15, 2025
2f5186a
add additional detail to README about diode credential
ltucker May 15, 2025
2279a97
OBS-1046 delete
arthanson May 15, 2025
c08aec7
Merge branch 'OBS-1046-client' of github.com:netboxlabs/diode-netbox-…
arthanson May 15, 2025
63ae897
fix: no response from delete, don't parse
ltucker May 15, 2025
205fb7a
OBS-1046 ui fixes
arthanson May 15, 2025
2e94b29
OBS-1046 ui fixes
arthanson May 15, 2025
9f88f6c
OBS-1046 ui fixes
arthanson May 15, 2025
b3301c5
OBS-1046 fix secret copy
arthanson May 15, 2025
d86815f
OBS-1046 fix delete cancel
arthanson May 15, 2025
69e60a1
fix: validate client id before requesting
ltucker May 16, 2025
1724ae6
add unit tests for diode clients api wrapper
ltucker May 16, 2025
ce7e982
Potential fix for code scanning alert no. 23: Log Injection
ltucker May 16, 2025
d309a0c
Potential fix for code scanning alert no. 25: Log Injection
ltucker May 16, 2025
9656e9b
Potential fix for code scanning alert no. 24: Log Injection
ltucker May 16, 2025
e6e1731
Potential fix for code scanning alert no. 22: URL redirection from re…
ltucker May 16, 2025
9e0af56
Potential fix for code scanning alert no. 26: Log Injection
ltucker May 16, 2025
4df7063
linting
ltucker May 16, 2025
fee9324
display specific message if no diode credential is configured
ltucker May 16, 2025
8700cfd
Update netbox_diode_plugin/views.py
ltucker May 16, 2025
7c07bfe
OBS-1046 update message
arthanson May 16, 2025
5721150
feat: client credentials management (#106)
ltucker May 16, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,5 @@ dist/
# Docker
docker/coverage
!docker/netbox/env
docker/oauth2/secrets/*
!docker/oauth2/secrets/.gitkeep
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,18 @@ 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": "..."
},
}
```

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.

Expand Down
3 changes: 2 additions & 1 deletion docker/docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ 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
- ./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
Expand Down
2 changes: 1 addition & 1 deletion docker/netbox/env/netbox.env
Original file line number Diff line number Diff line change
Expand Up @@ -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
Empty file.
9 changes: 9 additions & 0 deletions netbox_diode_plugin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}


Expand Down
36 changes: 36 additions & 0 deletions netbox_diode_plugin/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# !/usr/bin/env python
# Copyright 2025 NetBox Labs, Inc.
"""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)
221 changes: 221 additions & 0 deletions netbox_diode_plugin/diode/clients.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
#!/usr/bin/env python
# Copyright 2025 NetBox Labs Inc
"""Diode NetBox Plugin - Diode - Auth."""

import datetime
import json
import logging
import re
import threading
from dataclasses import dataclass
from urllib.parse import urlencode

import requests

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")

valid_client_id_re = re.compile(r"^[a-zA-Z0-9_-]{1,64}$")

_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()
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(),
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, json=data, headers=headers)
if response.status_code != 201:
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."""
if not valid_client_id_re.match(client_id):
raise ValueError(f"Invalid client ID: {client_id}")

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."""
if not valid_client_id_re.match(client_id):
raise ValueError(f"Invalid client ID: {client_id}")

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 != 204:
raise ClientAPIError(f"Failed to delete client {client_id}", response.status_code)
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}")
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 != 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

14 changes: 14 additions & 0 deletions netbox_diode_plugin/forms.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# !/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
Expand All @@ -9,6 +11,7 @@

__all__ = (
"SettingsForm",
"ClientCredentialForm",
)


Expand Down Expand Up @@ -40,3 +43,14 @@ 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,
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"}),
)
14 changes: 14 additions & 0 deletions netbox_diode_plugin/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,18 @@ 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"),
)

Loading