Skip to content

Commit

Permalink
Add Hashicorp Kv v1 support (#53)
Browse files Browse the repository at this point in the history
* add kv version argument

* first iter on doc & tests

* set v1 as if clause

* add kv V1 tests to harness

* add kv V1 related doc

* function inline doc on tests

* removed misplaced line

* add available choices to kv version parameter

Co-authored-by: nniehoff <github@nickniehoff.net>

* Black and bandit

* FINE

* Refactor tests

* Revert some whitespace changes

* Add explicit fallback test

* Update nautobot_secrets_providers/templates/nautobot_secrets_providers/home.html

Co-authored-by: Glenn Matthews <glenn.matthews@networktocode.com>

---------

Co-authored-by: nniehoff <github@nickniehoff.net>
Co-authored-by: Bryan Culver <31187+bryanculver@users.noreply.github.com>
Co-authored-by: Bryan Culver <bryan.culver@networktocode.com>
Co-authored-by: Glenn Matthews <glenn.matthews@networktocode.com>
  • Loading branch information
5 people committed Apr 19, 2023
1 parent d29afca commit 5355cf0
Show file tree
Hide file tree
Showing 6 changed files with 132 additions and 5 deletions.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ This plugin supports the following popular secrets backends:
| ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ |
| [AWS Secrets Manager](https://aws.amazon.com/secrets-manager/) | [Other: Key/value pairs](https://docs.aws.amazon.com/secretsmanager/latest/userguide/manage_create-basic-secret.html) | [AWS credentials](https://docs.aws.amazon.com/general/latest/gr/aws-sec-cred-types.html) (see Usage section below) |
| [AWS Systems Manager Parameter Store](https://aws.amazon.com/secrets-manager/) | [Other: Key/value pairs](https://docs.aws.amazon.com/systems-manager/latest/userguide/systems-manager-parameter-store.html) | [AWS credentials](https://docs.aws.amazon.com/general/latest/gr/aws-sec-cred-types.html) (see Usage section below) |
| [HashiCorp Vault](https://www.vaultproject.io) | [K/V Version 2](https://www.vaultproject.io/docs/secrets/kv/kv-v2) | [Token](https://www.vaultproject.io/docs/auth/token)<br/>[AppRole](https://www.vaultproject.io/docs/auth/approle)<br/>[AWS](https://www.vaultproject.io/docs/auth/aws)<br/>[Kubernetes](https://www.vaultproject.io/docs/auth/kubernetes) |
| [HashiCorp Vault](https://www.vaultproject.io) | [K/V Version 2](https://www.vaultproject.io/docs/secrets/kv/kv-v2)<br/>[K/V Version 1](https://developer.hashicorp.com/vault/docs/secrets/kv/kv-v1) | [Token](https://www.vaultproject.io/docs/auth/token)<br/>[AppRole](https://www.vaultproject.io/docs/auth/approle)<br/>[AWS](https://www.vaultproject.io/docs/auth/aws)<br/>[Kubernetes](https://www.vaultproject.io/docs/auth/kubernetes) |
| [Delinea/Thycotic Secret Server](https://delinea.com/products/secret-server) | [Secret Server Cloud](https://github.com/DelineaXPM/python-tss-sdk#secret-server-cloud)<br/>[Secret Server (on-prem)](https://github.com/DelineaXPM/python-tss-sdk#initializing-secretserver)| [Access Token Authorization](https://github.com/DelineaXPM/python-tss-sdk#access-token-authorization)<br/>[Domain Authorization](https://github.com/DelineaXPM/python-tss-sdk#domain-authorization)<br/>[Password Authorization](https://github.com/DelineaXPM/python-tss-sdk#password-authorization)<br/> |

## Screenshots
Expand Down Expand Up @@ -166,6 +166,7 @@ PLUGINS_CONFIG = {
- `auth_method` - (optional / defaults to "token") The method used to authenticate against the HashiCorp Vault instance. Either `"approle"`, `"aws"`, `"kubernetes"` or `"token"`. For information on using AWS authentication with vault see the [authentication](#authentication) section above.
- `ca_cert` - (optional) Path to a PEM formatted CA certificate to use when verifying the Vault connection. Can alternatively be set to `False` to ignore SSL verification (not recommended) or `True` to use the system certificates.
- `default_mount_point` - (optional / defaults to "secret") The default mount point of the K/V Version 2 secrets engine within Hashicorp Vault.
- `kv_version` - (optional / defaults to "v2") The version of the KV engine to use, can be `v1` or `v2`
- `k8s_token_path` - (optional) Path to the kubernetes service account token file. Defaults to "/var/run/secrets/kubernetes.io/serviceaccount/token".
- `token` - (optional) Required when `"auth_method": "token"` or `auth_method` is not supplied. The token for authenticating the client with the HashiCorp Vault instance. As with other sensitive service credentials, we recommend that you provide the token value as an environment variable and retrieve it with `{"token": os.getenv("NAUTOBOT_HASHICORP_VAULT_TOKEN")}` rather than hard-coding it in your `nautobot_config.py`.
- `role_name` - (optional) Required when `"auth_method": "kubernetes"`, optional when `"auth_method": "aws"`. The Vault Kubernetes role or Vault AWS role to assume which the pod's service account has access to.
Expand Down
Binary file modified docs/images/screenshot04.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 12 additions & 0 deletions nautobot_secrets_providers/providers/choices.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,15 @@ class ThycoticSecretChoices(ChoiceSet):
(SECRET_URL, "URL"),
(SECRET_NOTES, "Notes"),
)


class HashicorpKVVersionChoices(ChoiceSet):
"""Choices for Hashicorp KV Version."""

KV_VERSION_1 = "v1"
KV_VERSION_2 = "v2"

CHOICES = (
(KV_VERSION_1, "V1"),
(KV_VERSION_2, "V2"),
)
27 changes: 26 additions & 1 deletion nautobot_secrets_providers/providers/hashicorp.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
from nautobot.utilities.forms import BootstrapMixin
from nautobot.extras.secrets import exceptions, SecretsProvider

from .choices import HashicorpKVVersionChoices

__all__ = ("HashiCorpVaultSecretsProvider",)

K8S_TOKEN_DEFAULT_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/token" # nosec B105
Expand All @@ -29,6 +31,13 @@
except KeyError:
DEFAULT_MOUNT_POINT = "secret"

# Default kv version for the HVAC client
try:
plugins_config = settings.PLUGINS_CONFIG["nautobot_secrets_providers"]
DEFAULT_KV_VERSION = plugins_config["hashicorp_vault"]["default_kv_version"]
except KeyError:
DEFAULT_KV_VERSION = HashicorpKVVersionChoices.KV_VERSION_2


class HashiCorpVaultSecretsProvider(SecretsProvider):
"""A secrets provider for HashiCorp Vault."""
Expand All @@ -53,6 +62,12 @@ class ParametersForm(BootstrapMixin, forms.Form):
help_text=f"The path where the secret engine was mounted on (Default: <code>{DEFAULT_MOUNT_POINT}</code>)",
initial=DEFAULT_MOUNT_POINT,
)
kv_version = forms.ChoiceField(
required=False,
choices=HashicorpKVVersionChoices,
help_text=f"The version of the kv engine (either v1 or v2) (Default: <code>{DEFAULT_KV_VERSION}</code>)",
initial=DEFAULT_KV_VERSION,
)

@classmethod
def validate_vault_settings(cls, secret=None):
Expand All @@ -65,13 +80,17 @@ def validate_vault_settings(cls, secret=None):

vault_settings = plugin_settings.get("hashicorp_vault", {})
auth_method = vault_settings.get("auth_method", "token")
kv_version = vault_settings.get("kv_version", HashicorpKVVersionChoices.KV_VERSION_2)

if "url" not in vault_settings:
raise exceptions.SecretProviderError(secret, cls, "HashiCorp Vault configuration is missing a url")

if auth_method not in AUTH_METHOD_CHOICES:
raise exceptions.SecretProviderError(secret, cls, f"HashiCorp Vault Auth Method {auth_method} is invalid!")

if kv_version not in HashicorpKVVersionChoices.as_dict():
raise exceptions.SecretProviderError(secret, cls, f"HashiCorp Vault KV version {kv_version} is invalid!")

if auth_method == "aws":
if not boto3:
raise exceptions.SecretProviderError(
Expand Down Expand Up @@ -160,19 +179,25 @@ def get_value_for_secret(cls, secret, obj=None, **kwargs):
secret_path = parameters["path"]
secret_key = parameters["key"]
secret_mount_point = parameters.get("mount_point", DEFAULT_MOUNT_POINT)
secret_kv_version = parameters.get("kv_version", DEFAULT_KV_VERSION)
except KeyError as err:
msg = f"The secret parameter could not be retrieved for field {err}"
raise exceptions.SecretParametersError(secret, cls, msg) from err

client = cls.get_client(secret)

try:
response = client.secrets.kv.read_secret(path=secret_path, mount_point=secret_mount_point)
if secret_kv_version == HashicorpKVVersionChoices.KV_VERSION_1:
response = client.secrets.kv.v1.read_secret(path=secret_path, mount_point=secret_mount_point)
else:
response = client.secrets.kv.v2.read_secret(path=secret_path, mount_point=secret_mount_point)
except hvac.exceptions.InvalidPath as err:
raise exceptions.SecretValueNotFoundError(secret, cls, str(err)) from err

# Retrieve the value using the key or complain loudly.
try:
if secret_kv_version == HashicorpKVVersionChoices.KV_VERSION_1:
return response["data"][secret_key]
return response["data"]["data"][secret_key]
except KeyError as err:
msg = f"The secret value could not be retrieved using key {err}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ <h1>{% block title %}Secrets Providers Home{% endblock %}</h1>
</tr>
<tr>
<td><a href="https://www.vaultproject.io" rel="nofollow">HashiCorp Vault</a></td>
<td><a href="https://www.vaultproject.io/docs/secrets/kv/kv-v2" rel="nofollow">K/V Version 2</a></td>
<td><a href="https://www.vaultproject.io/docs/secrets/kv/kv-v1" rel="nofollow">K/V Version 1</a><br />
<a href="https://www.vaultproject.io/docs/secrets/kv/kv-v2" rel="nofollow">K/V Version 2</a></td>
<td><a href="https://www.vaultproject.io/docs/auth/token" rel="nofollow">Token</a><br/>
<a href="https://www.vaultproject.io/docs/auth/approle" rel="nofollow">AppRole</a></td>
</tr>
Expand Down
92 changes: 90 additions & 2 deletions nautobot_secrets_providers/tests/test_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
HashiCorpVaultSecretsProvider,
)

from nautobot_secrets_providers.providers.choices import HashicorpKVVersionChoices

# Use the proper swappable User model
User = get_user_model()
Expand Down Expand Up @@ -182,18 +183,105 @@ def setUp(self):
name="hello-hashicorp",
slug="hello-hashicorp",
provider=self.provider.slug,
parameters={"path": "hello", "key": "location"},
parameters={
"path": "hello",
"key": "location",
"kv_version": HashicorpKVVersionChoices.KV_VERSION_2,
},
)
# The secret with a mounting point we be using.
self.secret_mounting_point = Secret.objects.create(
name="hello-hashicorp-mntpnt",
slug="hello-hashicorp-mntpnt",
provider=self.provider.slug,
parameters={"path": "hello", "key": "location", "mount_point": "mymount"},
parameters={
"path": "hello",
"key": "location",
"mount_point": "mymount",
"kv_version": HashicorpKVVersionChoices.KV_VERSION_2,
},
)
self.test_path = "http://localhost:8200/v1/secret/data/hello"
self.test_mountpoint_path = "http://localhost:8200/v1/mymount/data/hello"

@requests_mock.Mocker()
def test_v1(self, requests_mocker):
mock_kv_v1_response = {
"request_id": "f0185257-af7a-f550-2d9a-ada457a70e17",
"lease_id": "",
"renewable": False,
"lease_duration": 0,
"data": {
"location": "world",
},
"wrap_info": None,
"warnings": None,
"auth": None,
}
kv_v1_test_path = "http://localhost:8200/v1/secret/hello"
kv_v1_test_mountpoint_path = "http://localhost:8200/v1/mymount/hello"
kv_v1_secret = Secret.objects.create(
name="hello-hashicorp-v1",
slug="hello-hashicorp-v1",
provider=self.provider.slug,
parameters={"path": "hello", "key": "location", "kv_version": HashicorpKVVersionChoices.KV_VERSION_1},
)
kv_v1_secret_mounting_point = Secret.objects.create(
name="hello-hashicorp-mntpnt-v1",
slug="hello-hashicorp-mntpnt-v1",
provider=self.provider.slug,
parameters={
"path": "hello",
"key": "location",
"mount_point": "mymount",
"kv_version": HashicorpKVVersionChoices.KV_VERSION_1,
},
)

with self.subTest("Test v1 retrieve success"):
requests_mocker.register_uri(method="GET", url=kv_v1_test_path, json=mock_kv_v1_response)

response = self.provider.get_value_for_secret(kv_v1_secret)
self.assertEqual(mock_kv_v1_response["data"]["location"], response)

with self.subTest("Test v1 retrieve success with mount point set"):
requests_mocker.register_uri(method="GET", url=kv_v1_test_mountpoint_path, json=mock_kv_v1_response)

response = self.provider.get_value_for_secret(kv_v1_secret_mounting_point)
self.assertEqual(mock_kv_v1_response["data"]["location"], response)

@requests_mock.Mocker()
def test_v2_fallback(self, requests_mocker):
"""
Before https://github.com/nautobot/nautobot-plugin-secrets-providers/pull/53 was merged, the Hashicorp
provider would only support KV v2 and did not include a way to specify the KV version.
This test ensures that the provider will still work without the kv_version parameter.
"""
kv_v2_fallback_secret = Secret.objects.create(
name="hello-hashicorp-v2-fallback",
slug="hello-hashicorp-v2-fallback",
provider=self.provider.slug,
parameters={"path": "hello", "key": "location"},
)
kv_v2_fallback_secret_mounting_point = Secret.objects.create(
name="hello-hashicorp-mntpnt-v2-fallback",
slug="hello-hashicorp-mntpnt-v2-fallback",
provider=self.provider.slug,
parameters={"path": "hello", "key": "location", "mount_point": "mymount"},
)

with self.subTest("Test v2 fallback retrieve success"):
requests_mocker.register_uri(method="GET", url=self.test_path, json=self.mock_response)

response = self.provider.get_value_for_secret(kv_v2_fallback_secret)
self.assertEqual(self.mock_response["data"]["data"]["location"], response)

with self.subTest("Test v2 fallback retrieve success with mount point set"):
requests_mocker.register_uri(method="GET", url=self.test_mountpoint_path, json=self.mock_response)

response = self.provider.get_value_for_secret(kv_v2_fallback_secret_mounting_point)
self.assertEqual(self.mock_response["data"]["data"]["location"], response)

@requests_mock.Mocker()
def test_retrieve_success(self, requests_mocker):
"""Retrieve a secret successfully."""
Expand Down

0 comments on commit 5355cf0

Please sign in to comment.