diff --git a/docs/secrets.md b/docs/secrets.md index 131be84..10cbf49 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -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. diff --git a/netbox_config_diff/__init__.py b/netbox_config_diff/__init__.py index a794434..5a83cf0 100644 --- a/netbox_config_diff/__init__.py +++ b/netbox_config_diff/__init__.py @@ -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"], } diff --git a/netbox_config_diff/compliance/secrets.py b/netbox_config_diff/compliance/secrets.py index b41c079..db9223e 100644 --- a/netbox_config_diff/compliance/secrets.py +++ b/netbox_config_diff/compliance/secrets.py @@ -1,4 +1,5 @@ import base64 +from operator import attrgetter from typing import TYPE_CHECKING from dcim.models import Device @@ -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 @@ -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") diff --git a/tests/test_secrets.py b/tests/test_secrets.py new file mode 100644 index 0000000..aeabd25 --- /dev/null +++ b/tests/test_secrets.py @@ -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", + )