Skip to content

Commit 84824cf

Browse files
authored
🚚 release (#107)
adds a user interface for managing diode client credentials adds additional plugin configuration for authenticating calls to diode from netbox plugin
2 parents ef49198 + 5721150 commit 84824cf

21 files changed

+1157
-17
lines changed

‎.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,5 @@ dist/
2929
# Docker
3030
docker/coverage
3131
!docker/netbox/env
32+
docker/oauth2/secrets/*
33+
!docker/oauth2/secrets/.gitkeep

‎README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,18 @@ PLUGINS_CONFIG = {
5252

5353
# Username associated with changes applied via plugin
5454
"diode_username": "diode",
55+
56+
# netbox-to-diode client_secret created during diode bootstrap.
57+
"netbox_to_diode_client_secret": "..."
5558
},
5659
}
5760
```
5861

62+
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:
63+
```
64+
echo $(jq -r '.[] | select(.client_id == "netbox-to-diode") | .client_secret' /path/to/diode/oauth2/client/client-credentials.json)
65+
```
66+
5967
Note: Once you customise usernames with PLUGINS_CONFIG during first installation, you should not change or remove them
6068
later on. Doing so will cause the plugin to stop working properly.
6169

‎docker/docker-compose.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ services:
2020
volumes:
2121
- ./netbox/docker-entrypoint.sh:/opt/netbox/docker-entrypoint.sh:z,ro
2222
- ./netbox/nginx-unit.json:/opt/netbox/nginx-unit.json:z,ro
23-
- ../netbox_diode_plugin:/opt/netbox/netbox/netbox_diode_plugin:ro
23+
- ../netbox_diode_plugin:/opt/netbox/netbox/netbox_diode_plugin:z,rw
24+
- ./oauth2/secrets:/run/secrets:z,ro
2425
- ./netbox/launch-netbox.sh:/opt/netbox/launch-netbox.sh:z,ro
2526
- ./netbox/plugins_dev.py:/etc/netbox/config/plugins.py:z,ro
2627
- ./coverage:/opt/netbox/netbox/coverage:z,rw

‎docker/netbox/env/netbox.env

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,6 @@ SUPERUSER_EMAIL=
3636
SUPERUSER_NAME=admin
3737
SUPERUSER_PASSWORD=admin
3838
WEBHOOKS_ENABLED=true
39-
RELOAD_NETBOX_ON_DIODE_PLUGIN_CHANGE=false
39+
RELOAD_NETBOX_ON_DIODE_PLUGIN_CHANGE=true
4040
BASE_PATH=netbox/
4141
DEBUG=False

‎docker/oauth2/secrets/.gitkeep

Whitespace-only changes.

‎netbox_diode_plugin/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,15 @@ class NetBoxDiodePluginConfig(PluginConfig):
2222

2323
# Default username associated with changes applied via plugin
2424
"diode_username": "diode",
25+
26+
# client_id and client_secret for communication with Diode server.
27+
# By default, the secret is read from a file /run/secrets/netbox_to_diode
28+
# but may be specified directly as a string in netbox_to_diode_client_secret
29+
"netbox_to_diode_client_id": "netbox-to-diode",
30+
"netbox_to_diode_client_secret": None,
31+
"secrets_path": "/run/secrets/",
32+
"netbox_to_diode_client_secret_name": "netbox_to_diode",
33+
"diode_max_auth_retries": 3,
2534
}
2635

2736

‎netbox_diode_plugin/client.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# !/usr/bin/env python
2+
# Copyright 2025 NetBox Labs, Inc.
3+
"""Diode NetBox Plugin - Client."""
4+
5+
import logging
6+
7+
from netbox_diode_plugin.diode.clients import get_api_client
8+
9+
logger = logging.getLogger("netbox.diode_data")
10+
11+
12+
def create_client(request, client_name: str, scope: str):
13+
"""Create client."""
14+
logger.info(f"Creating client {client_name} with scope {scope}")
15+
return get_api_client().create_client(client_name, scope)
16+
17+
18+
def delete_client(request, client_id: str):
19+
"""Delete client."""
20+
sanitized_client_id = client_id.replace("\n", "").replace("\r", "")
21+
logger.info(f"Deleting client {sanitized_client_id}")
22+
return get_api_client().delete_client(client_id)
23+
24+
25+
def list_clients(request):
26+
"""List clients."""
27+
logger.info("Listing clients")
28+
response = get_api_client().list_clients()
29+
return response["data"]
30+
31+
32+
def get_client(request, client_id: str):
33+
"""Get client."""
34+
sanitized_client_id = client_id.replace("\n", "").replace("\r", "")
35+
logger.info(f"Getting client {sanitized_client_id}")
36+
return get_api_client().get_client(client_id)
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
#!/usr/bin/env python
2+
# Copyright 2025 NetBox Labs Inc
3+
"""Diode NetBox Plugin - Diode - Auth."""
4+
5+
import datetime
6+
import json
7+
import logging
8+
import re
9+
import threading
10+
from dataclasses import dataclass
11+
from urllib.parse import urlencode
12+
13+
import requests
14+
15+
from netbox_diode_plugin.plugin_config import (
16+
get_diode_auth_base_url,
17+
get_diode_credentials,
18+
get_diode_max_auth_retries,
19+
)
20+
21+
SCOPE_DIODE_READ = "diode:read"
22+
SCOPE_DIODE_WRITE = "diode:write"
23+
24+
logger = logging.getLogger("netbox.diode_data")
25+
26+
valid_client_id_re = re.compile(r"^[a-zA-Z0-9_-]{1,64}$")
27+
28+
_client = None
29+
_client_lock = threading.Lock()
30+
def get_api_client():
31+
"""Get the client API client."""
32+
global _client
33+
global _client_lock
34+
35+
with _client_lock:
36+
if _client is None:
37+
client_id, client_secret = get_diode_credentials()
38+
if not client_id:
39+
raise ClientAPIError(
40+
"Please update the plugin configuration to access this feature.\nMissing netbox to diode client id.", 500)
41+
if not client_secret:
42+
raise ClientAPIError(
43+
"Please update the plugin configuration to access this feature.\nMissing netbox to diode client secret.", 500)
44+
max_auth_retries = get_diode_max_auth_retries()
45+
_client = ClientAPI(
46+
base_url=get_diode_auth_base_url(),
47+
client_id=client_id,
48+
client_secret=client_secret,
49+
max_auth_retries=max_auth_retries,
50+
)
51+
return _client
52+
53+
54+
class ClientAPIError(Exception):
55+
"""Client API Error."""
56+
57+
def __init__(self, message: str, status_code: int = 500):
58+
"""Initialize the ClientAPIError."""
59+
self.message = message
60+
self.status_code = status_code
61+
super().__init__(self.message)
62+
63+
def is_auth_error(self) -> bool:
64+
"""Check if the error is an authentication error."""
65+
return self.status_code == 401 or self.status_code == 403
66+
67+
class ClientAPI:
68+
"""Manages Diode Clients."""
69+
70+
def __init__(self, base_url: str, client_id: str, client_secret: str, max_auth_retries: int = 2):
71+
"""Initialize the ClientAPI."""
72+
self.base_url = base_url
73+
self.client_id = client_id
74+
self.client_secret = client_secret
75+
76+
self._max_auth_retries = max_auth_retries
77+
self._client_auth_token = None
78+
self._client_auth_token_lock = threading.Lock()
79+
80+
def create_client(self, name: str, scope: str) -> dict:
81+
"""Create a client."""
82+
for attempt in range(self._max_auth_retries):
83+
token = None
84+
try:
85+
token = self._get_token()
86+
url = self.base_url + "/clients"
87+
headers = {"Authorization": f"Bearer {token}"}
88+
data = {
89+
"client_name": name,
90+
"scope": scope,
91+
}
92+
response = requests.post(url, json=data, headers=headers)
93+
if response.status_code != 201:
94+
raise ClientAPIError("Failed to create client", response.status_code)
95+
return response.json()
96+
except ClientAPIError as e:
97+
if e.is_auth_error() and attempt < self._max_auth_retries - 1:
98+
logger.info(f"Retrying create_client due to unauthenticated error, attempt {attempt + 1}")
99+
self._mark_client_auth_token_invalid(token)
100+
continue
101+
raise
102+
raise ClientAPIError("Failed to create client: unexpected state", 500)
103+
104+
def get_client(self, client_id: str) -> dict:
105+
"""Get a client."""
106+
if not valid_client_id_re.match(client_id):
107+
raise ValueError(f"Invalid client ID: {client_id}")
108+
109+
for attempt in range(self._max_auth_retries):
110+
token = None
111+
try:
112+
token = self._get_token()
113+
url = self.base_url + f"/clients/{client_id}"
114+
headers = {"Authorization": f"Bearer {token}"}
115+
response = requests.get(url, headers=headers)
116+
if response.status_code == 401 or response.status_code == 403:
117+
raise ClientAPIError(f"Failed to get client {client_id}", response.status_code)
118+
if response.status_code != 200:
119+
raise ClientAPIError(f"Failed to get client {client_id}", response.status_code)
120+
return response.json()
121+
except ClientAPIError as e:
122+
if e.is_auth_error() and attempt < self._max_auth_retries - 1:
123+
logger.info(f"Retrying delete_client due to unauthenticated error, attempt {attempt + 1}")
124+
self._mark_client_auth_token_invalid(token)
125+
continue
126+
raise
127+
raise ClientAPIError(f"Failed to get client {client_id}: unexpected state")
128+
129+
def delete_client(self, client_id: str) -> None:
130+
"""Delete a client."""
131+
if not valid_client_id_re.match(client_id):
132+
raise ValueError(f"Invalid client ID: {client_id}")
133+
134+
for attempt in range(self._max_auth_retries):
135+
token = None
136+
try:
137+
token = self._get_token()
138+
url = self.base_url + f"/clients/{client_id}"
139+
headers = {"Authorization": f"Bearer {token}"}
140+
response = requests.delete(url, headers=headers)
141+
if response.status_code != 204:
142+
raise ClientAPIError(f"Failed to delete client {client_id}", response.status_code)
143+
return
144+
except ClientAPIError as e:
145+
if e.is_auth_error() and attempt < self._max_auth_retries - 1:
146+
logger.info(f"Retrying delete_client due to unauthenticated error, attempt {attempt + 1}")
147+
self._mark_client_auth_token_invalid(token)
148+
continue
149+
raise
150+
raise ClientAPIError(f"Failed to delete client {client_id}: unexpected state")
151+
152+
153+
def list_clients(self, page_token: str | None = None, page_size: int | None = None) -> list[dict]:
154+
"""List all clients."""
155+
for attempt in range(self._max_auth_retries):
156+
token = None
157+
try:
158+
token = self._get_token()
159+
url = self.base_url + "/clients"
160+
headers = {"Authorization": f"Bearer {token}"}
161+
params = {}
162+
if page_token:
163+
params["page_token"] = page_token
164+
if page_size:
165+
params["page_size"] = page_size
166+
response = requests.get(url, headers=headers, params=params)
167+
if response.status_code != 200:
168+
raise ClientAPIError("Failed to get clients", response.status_code)
169+
return response.json()
170+
except ClientAPIError as e:
171+
if e.is_auth_error() and attempt < self._max_auth_retries - 1:
172+
logger.info(f"Retrying list_clients due to unauthenticated error, attempt {attempt + 1}")
173+
self._mark_client_auth_token_invalid(token)
174+
continue
175+
raise
176+
raise ClientAPIError("Failed to list clients: unexpected state")
177+
178+
179+
def _get_token(self) -> str:
180+
"""Get a token for the Diode Auth Service."""
181+
with self._client_auth_token_lock:
182+
if self._client_auth_token:
183+
return self._client_auth_token
184+
self._client_auth_token = self._authenticate()
185+
return self._client_auth_token
186+
187+
def _mark_client_auth_token_invalid(self, token: str):
188+
"""Mark a client auth token as invalid."""
189+
with self._client_auth_token_lock:
190+
self._client_auth_token = None
191+
192+
def _authenticate(self) -> str:
193+
"""Get a new access token for the Diode Auth Service."""
194+
headers = {"Content-Type": "application/x-www-form-urlencoded"}
195+
data = urlencode(
196+
{
197+
"grant_type": "client_credentials",
198+
"client_id": self.client_id,
199+
"client_secret": self.client_secret,
200+
"scope": f"{SCOPE_DIODE_READ} {SCOPE_DIODE_WRITE}",
201+
}
202+
)
203+
url = self.base_url + "/token"
204+
try:
205+
response = requests.post(url, data=data, headers=headers)
206+
except Exception as e:
207+
raise ClientAPIError(f"Failed to obtain access token: {e}", 401) from e
208+
if response.status_code != 200:
209+
raise ClientAPIError(f"Failed to obtain access token: {response.reason}", 401)
210+
211+
try:
212+
token_info = response.json()
213+
except Exception as e:
214+
raise ClientAPIError(f"Failed to parse access token response: {e}", 401) from e
215+
216+
access_token = token_info.get("access_token")
217+
if not access_token:
218+
raise ClientAPIError(f"Failed to obtain access token for client {self._client_id}", 401)
219+
220+
return access_token
221+

‎netbox_diode_plugin/forms.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# !/usr/bin/env python
22
# Copyright 2025 NetBox Labs, Inc.
33
"""Diode NetBox Plugin - Forms."""
4+
from django import forms
5+
from django.utils.translation import gettext_lazy as _
46
from netbox.forms import NetBoxModelForm
57
from netbox.plugins import get_plugin_config
68
from utilities.forms.rendering import FieldSet
@@ -9,6 +11,7 @@
911

1012
__all__ = (
1113
"SettingsForm",
14+
"ClientCredentialForm",
1215
)
1316

1417

@@ -40,3 +43,14 @@ def __init__(self, *args, **kwargs):
4043
self.fields["diode_target"].help_text = (
4144
"This field is not allowed to be modified."
4245
)
46+
47+
48+
class ClientCredentialForm(forms.Form):
49+
"""Form for adding client credentials."""
50+
51+
client_name = forms.CharField(
52+
label=_("Client Name"),
53+
required=True,
54+
help_text=_("Enter a name for the client credential that will be created for authentication to the Diode ingestion service."),
55+
widget=forms.TextInput(attrs={"class": "form-control"}),
56+
)

‎netbox_diode_plugin/models.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,18 @@ def get_absolute_url(self):
4040
return reverse("plugins:netbox_diode_plugin:settings")
4141

4242

43+
class ClientCredentials(models.Model):
44+
"""Dummy model to allow for permissions, saved filters, etc.."""
45+
46+
class Meta:
47+
"""Meta class."""
48+
49+
managed = False
50+
51+
default_permissions = ()
52+
53+
permissions = (
54+
("view_clientcredentials", "Can view Client Credentials"),
55+
("add_clientcredentials", "Can perform actions on Client Credentials"),
56+
)
4357

0 commit comments

Comments
 (0)