Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 18 additions & 2 deletions docs/secrets.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,25 @@ PLUGINS_CONFIG = {
}
```

Script will find secrets with these roles attached to the device and use them as credentials.
Script will find secrets with these roles and use them as credentials in this order:

1. Device secrets
2. Device role secrets (if [enabled](https://github.com/Onemind-Services-LLC/netbox-secrets/blob/master/docs/installation.md#apps))
3. Platform secrets (if enabled)

The first non-empty value found for each credential is used.

You can change the precedence order via plugin configuration `SECRETS_PRECEDENCE`. It accepts a list of attribute names that will be resolved against the `Device` object. Use the special name `device` to refer to the device itself. Invalid names are ignored. Default value:

```python
PLUGINS_CONFIG = {
"netbox_config_diff": {
"SECRETS_PRECEDENCE": ["device", "role", "platform"],
}
}
```

Also you can define secret role for desired privilege level in plugins variable `DEFAULT_DESIRED_PRIVILEGE_LEVEL_ROLE`
or can specify the desired privilege level itself in variable `DEFAULT_DESIRED_PRIVILEGE_LEVEL`.

If something goes wrong, then credentials from `PLUGINS_CONFIG` will be used.
If no matching secret is found (or a secret cannot be decrypted), credentials from `PLUGINS_CONFIG` are used.
1 change: 1 addition & 0 deletions netbox_config_diff/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class ConfigDiffConfig(PluginConfig):
"PASSWORD_SECRET_ROLE": "Password",
"SECOND_AUTH_SECRET_ROLE": "Second Auth",
"PATH_TO_SSH_CONFIG_FILE": "",
"SECRETS_PRECEDENCE": ["device", "role", "platform"],
}


Expand Down
54 changes: 35 additions & 19 deletions netbox_config_diff/compliance/secrets.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import base64
from operator import attrgetter
from typing import TYPE_CHECKING

from dcim.models import Device
Expand Down Expand Up @@ -49,28 +50,42 @@ def get_secret(self, secret: "Secret") -> str | None:
return None
return secret.plaintext

def get_secret_value(self, objects: tuple[object | None, ...], role_name: str) -> str | None:
for obj in objects:
if not obj:
continue

secrets = getattr(obj, "secrets", None)
if not secrets:
continue

if secret := secrets.filter(role__name=role_name).first():
if value := self.get_secret(secret):
return value

return None

def get_credentials(self, device: Device) -> tuple[str, str, str, str]:
if not self.netbox_secrets_installed:
if not self.netbox_secrets_installed or not self.secrets_precedence:
return self.username, self.password, self.auth_secondary, self.default_desired_privilege_level

if secret := device.secrets.filter(role__name=self.user_role).first():
username = value if (value := self.get_secret(secret)) else self.username
else:
username = self.username
if secret := device.secrets.filter(role__name=self.password_role).first():
password = value if (value := self.get_secret(secret)) else self.password
else:
password = self.password
if secret := device.secrets.filter(role__name=self.auth_secondary_role).first():
auth_secondary = value if (value := self.get_secret(secret)) else self.auth_secondary
else:
auth_secondary = self.auth_secondary
if secret := device.secrets.filter(role__name=self.default_desired_privilege_level_role).first():
default_desired_privilege_level = (
value if (value := self.get_secret(secret)) else self.default_desired_privilege_level
)
else:
default_desired_privilege_level = self.default_desired_privilege_level
secret_objects: list[object] = []
for entry in self.secrets_precedence:
if entry == "device":
secret_objects.append(device)
continue
try:
secret_objects.append(attrgetter(entry)(device))
except AttributeError:
pass

username = self.get_secret_value(secret_objects, self.user_role) or self.username
password = self.get_secret_value(secret_objects, self.password_role) or self.password
auth_secondary = self.get_secret_value(secret_objects, self.auth_secondary_role) or self.auth_secondary
default_desired_privilege_level = (
self.get_secret_value(secret_objects, self.default_desired_privilege_level_role)
or self.default_desired_privilege_level
)

return username, password, auth_secondary, default_desired_privilege_level

Expand All @@ -83,6 +98,7 @@ def check_netbox_secrets(self) -> None:
self.default_desired_privilege_level_role = get_plugin_config(
"netbox_config_diff", "DEFAULT_DESIRED_PRIVILEGE_LEVEL_ROLE"
)
self.secrets_precedence = get_plugin_config("netbox_config_diff", "SECRETS_PRECEDENCE")
self.netbox_secrets_installed = True

self.username = get_plugin_config("netbox_config_diff", "USERNAME")
Expand Down
135 changes: 135 additions & 0 deletions tests/test_secrets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
from types import SimpleNamespace

from netbox_config_diff.compliance.secrets import SecretsMixin


class DummySecretsManager:
def __init__(self, by_role: dict[str, object]) -> None:
self.by_role = by_role

def filter(self, **kwargs):
return SimpleNamespace(first=lambda: self.by_role.get(kwargs["role__name"]))


class DummySecretsMixin(SecretsMixin):
pass


def build_mixin() -> DummySecretsMixin:
mixin = DummySecretsMixin()
mixin.netbox_secrets_installed = True
mixin.username = "default-user"
mixin.password = "default-pass"
mixin.auth_secondary = "default-secondary"
mixin.default_desired_privilege_level = "default-priv"
mixin.secrets_precedence = ["device", "role", "platform"]

mixin.user_role = "user-role"
mixin.password_role = "password-role"
mixin.auth_secondary_role = "secondary-role"
mixin.default_desired_privilege_level_role = "priv-role"
return mixin


def test_get_credentials_prefers_device_then_role_then_platform() -> None:
mixin = build_mixin()

device = SimpleNamespace(
secrets=DummySecretsManager(
{
"user-role": SimpleNamespace(value="device-user"),
}
),
role=SimpleNamespace(
secrets=DummySecretsManager(
{
"user-role": SimpleNamespace(value="role-user"),
"password-role": SimpleNamespace(value="role-pass"),
}
)
),
platform=SimpleNamespace(
secrets=DummySecretsManager(
{
"user-role": SimpleNamespace(value="platform-user"),
"password-role": SimpleNamespace(value="platform-pass"),
"secondary-role": SimpleNamespace(value="platform-secondary"),
"priv-role": SimpleNamespace(value="platform-priv"),
}
)
),
)
mixin.get_secret = lambda secret: secret.value

assert mixin.get_credentials(device) == (
"device-user",
"role-pass",
"platform-secondary",
"platform-priv",
)


def test_get_credentials_skips_empty_secret_value() -> None:
mixin = build_mixin()

device = SimpleNamespace(
secrets=DummySecretsManager(
{
"password-role": SimpleNamespace(value=""),
}
),
role=SimpleNamespace(
secrets=DummySecretsManager(
{
"password-role": SimpleNamespace(value="role-pass"),
}
)
),
platform=SimpleNamespace(secrets=DummySecretsManager({})),
)
mixin.get_secret = lambda secret: secret.value

username, password, auth_secondary, default_desired_privilege_level = mixin.get_credentials(device)

assert username == "default-user"
assert password == "role-pass"
assert auth_secondary == "default-secondary"
assert default_desired_privilege_level == "default-priv"


def test_get_credentials_respects_custom_precedence() -> None:
mixin = build_mixin()

device = SimpleNamespace(
secrets=DummySecretsManager(
{
"password-role": SimpleNamespace(value="device-pass"),
}
),
role=SimpleNamespace(
secrets=DummySecretsManager(
{
"user-role": SimpleNamespace(value="role-user"),
"password-role": SimpleNamespace(value="role-pass"),
}
)
),
platform=SimpleNamespace(
secrets=DummySecretsManager(
{
"user-role": SimpleNamespace(value="platform-user"),
}
)
),
)
mixin.get_secret = lambda secret: secret.value

# Force precedence: platform -> device -> role
mixin.secrets_precedence = ["platform", "device", "role"]

assert mixin.get_credentials(device) == (
"platform-user",
"device-pass",
"default-secondary",
"default-priv",
)