Skip to content

Commit

Permalink
Implement 1Password Vault (#155)
Browse files Browse the repository at this point in the history
* Initial work for adding 1Password support.

* Implement tests and docs.

* Update pyproject.toml

Fix onepassword-sdk install.

* Update Lock File

* Use onepassword-sdk and fix imports

* Create Change Fragment

* Update docs/admin/providers/onepassword_setup.md

Co-authored-by: Glenn Matthews <[email protected]>

* Docs update

* Update docs/admin/install.md

Co-authored-by: Gary Snider <[email protected]>

* Update docs/admin/providers/onepassword_setup.md

Co-authored-by: Gary Snider <[email protected]>

* Replace asyncio with django built-in async.

* Ruff and Docs update.

* Update onepassword_setup.md

Reword

* Address Feedback

---------

Co-authored-by: Glenn Matthews <[email protected]>
Co-authored-by: Gary Snider <[email protected]>
  • Loading branch information
3 people authored Oct 15, 2024
1 parent 96c9cf9 commit 88ad234
Show file tree
Hide file tree
Showing 18 changed files with 459 additions and 4 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ This app supports the following popular secrets backends:

| Secrets Backend | Supported Secret Types | Supported Authentication Methods |
| ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [1Password](https://1password.com) | [Hosted Password Management](https://1password.com/password-management) | [Service Account Token](https://developer.1password.com/docs/service-accounts/) |
| [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) |
| [Azure Key Vault](https://learn.microsoft.com/en-us/azure/key-vault/) | [Key Vault Secrets](https://learn.microsoft.com/en-us/azure/key-vault/secrets/about-secrets) | [Entra ID Service Principal](https://learn.microsoft.com/en-us/python/api/azure-identity/azure.identity.environmentcredential?view=azure-python) |
Expand Down
1 change: 1 addition & 0 deletions changes/88.added
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added 1Password as a Secrets Provider.
6 changes: 6 additions & 0 deletions development/nautobot_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,5 +163,11 @@
},
}
},
"one_password": {
"vaults": {},
"token": os.getenv(
"OP_SERVICE_ACCOUNT_TOKEN",
),
},
},
}
11 changes: 11 additions & 0 deletions docs/admin/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,17 @@ The HashiCorp Vault provider requires the `hvac` library. This can easily be ins
pip install nautobot-secrets-providers[hashicorp]
```

#### 1Password Vault

The 1Password Vault provider requires the `onepassword-sdk` library. This can easily be installed along with the app using the following command.

```no-highlight
pip install nautobot-secrets-providers[onepassword]
```

!!! note
The 1Password Vault requires a minimum version of Python 3.9.

### Access Requirements

There are no special access requirements to install the app.
Expand Down
1 change: 1 addition & 0 deletions docs/admin/providers/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ This Nautobot app supports the following secrets providers:
- [Azure](./azure_setup.md)
- [Delinea/Thycotic](./delinea_setup.md)
- [HashiCorp](./hashicorp_setup.md)
- [1Password](onepassword_setup.md)
34 changes: 34 additions & 0 deletions docs/admin/providers/onepassword_setup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# 1Password Vault

Requires a minimum of Python3.9

## Prerequisites

You must create a Service Account for the 1Password vault/vaults you are trying to access. You can follow the [Getting Started with Service Accounts](https://developer.1password.com/docs/service-accounts/get-started/) to assist with creating the Service Account.

!!! note
The Service Account token needs to have access to the Vault that it is configured for. Per 1Password policy "You can't grant a service account access to your built-in Personal, Private, or Employee vault, or your default Shared vault."

## Configuration

You must provide a mapping in `PLUGINS_CONFIG` within your `nautobot_config.py`, for example:

```python
PLUGINS_CONFIG = {
"nautobot_secrets_providers": {
"one_password": {
"token": os.environ.get("OP_SERVICE_ACCOUNT_TOKEN"),
"vaults": {
"MyVault": {
"token": os.environ.get("OP_SERVICE_ACCOUNT_TOKEN"),
},
},
},
},
}
```

- `token` - (required) The 1Password Service Account Token to be used globally when it is not specified by a vault.
- `vaults` (required) Each 1Password Vault that is supported by this app will be listed inside this dictionary.
- `<vault_name>` (required) The name of the vault needs to be placed as a key inside the `vaults` dictionary.
- `token` (optional) The 1Password Service Account Token to be used by the above vault, if overriding the global `token`.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/dark/secrets-providers-home.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/light/secrets-providers-home.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 8 additions & 2 deletions docs/user/app_use_cases.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ This document describes common use-cases and scenarios for this App.

---

![Screenshot of plugin home page](../images/secrets-providers-home.png "App Home page")
![Screenshot of plugin home page](../images/light/secrets-providers-home.png#only-light "App Home page")
![Screenshot of plugin home page](../images/dark/secrets-providers-home.png#only-dark "App Home page")

---

Expand All @@ -32,4 +33,9 @@ This document describes common use-cases and scenarios for this App.

---

![Screenshot of secret using Azure Key Vault](../images/azure-key-vault-secrets-provider-add.png "Secret using Azure Key Vault")
![Screenshot of secret using Azure Key Vault](../images/azure-key-vault-secrets-provider-add.png "Secret using Azure Key Vault")

---

![Screenshot of secret using 1Password](../images/light/1password-vault-secrets-provider-add.png#only-light "Secret using 1Password")
![Screenshot of secret using 1Password](../images/dark/1password-vault-secrets-provider-add.png#only-dark "Secret using 1Password")
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ nav:
- Install and Configure: "admin/install.md"
- Provider Setup:
- "admin/providers/index.md"
- 1Password: "admin/providers/onepassword_setup.md"
- AWS: "admin/providers/aws_setup.md"
- Azure: "admin/providers/azure_setup.md"
- Delinea/Thycotic: "admin/providers/delinea_setup.md"
Expand Down
2 changes: 2 additions & 0 deletions nautobot_secrets_providers/providers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from .azure import AzureKeyVaultSecretsProvider
from .delinea import DelineaSecretServerSecretsProviderId, DelineaSecretServerSecretsProviderPath
from .hashicorp import HashiCorpVaultSecretsProvider
from .one_password import OnePasswordSecretsProvider

__all__ = (
"AWSSecretsManagerSecretsProvider",
Expand All @@ -12,4 +13,5 @@
"DelineaSecretServerSecretsProviderId",
"DelineaSecretServerSecretsProviderPath",
"HashiCorpVaultSecretsProvider",
"OnePasswordSecretsProvider",
)
109 changes: 109 additions & 0 deletions nautobot_secrets_providers/providers/one_password.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
"""1Password Secrets Provider for Nautobot."""

from asgiref.sync import async_to_sync
from django import forms
from django.conf import settings
from nautobot.core.forms import BootstrapMixin
from nautobot.extras.secrets import SecretsProvider, exceptions

try:
from onepassword.client import Client
except ImportError:
Client = None

from nautobot_secrets_providers import __version__

__all__ = ("OnePasswordSecretsProvider",)


@async_to_sync
async def get_secret_from_vault(vault, item, field, token, section=None):
"""Get a secret from a 1Password vault.
Args:
vault (str): 1Password Vault where the secret is located.
item (str): 1Password Item where the secret is located.
field (str): 1Password secret field name.
token (str): 1Password Service Account token.
section (str, optional): 1Password Item Section for the secret. Defaults to None.
Returns:
(str): Value from the secret.
"""
client = await Client.authenticate(
auth=token, integration_name="nautobot-secrets-providers", integration_version=__version__
)
reference = f"op://{vault}/{item}/{f'{section}/' if section else ''}{field}"
return await client.secrets.resolve(reference)


def vault_choices():
"""Generate Choices for vault form field.
Build a form option for each key in vaults.
"""
plugin_settings = settings.PLUGINS_CONFIG["nautobot_secrets_providers"]
return [(key, key) for key in plugin_settings["one_password"]["vaults"].keys()]


class OnePasswordSecretsProvider(SecretsProvider):
"""A secrets provider for 1Password."""

slug = "one-password"
name = "1Password Vault"
is_available = Client is not None

# TBD: Remove after pylint-nautobot bump
# pylint: disable-next=nb-incorrect-base-class
class ParametersForm(BootstrapMixin, forms.Form):
"""Required parameters for HashiCorp Vault."""

vault = forms.ChoiceField(
required=True,
choices=vault_choices,
help_text="1Password Vault to retrieve the secret from.",
)
item = forms.CharField(
required=True,
help_text="The item in 1Password.",
)
section = forms.CharField(
required=False,
help_text="The section where the field is a part of.",
)
field = forms.CharField(
required=True,
help_text="The field where the secret is located. Defaults to 'password'.",
initial="password",
)

@classmethod
def get_token(cls, secret, vault):
"""Get the token for a vault."""
plugin_settings = settings.PLUGINS_CONFIG["nautobot_secrets_providers"]
if "token" in plugin_settings["one_password"]["vaults"][vault]:
return plugin_settings["one_password"]["vaults"][vault]["token"]
try:
return plugin_settings["one_password"]["token"]
except KeyError as exc:
raise exceptions.SecretProviderError(secret, cls, "1Password token is not configured!") from exc

@classmethod
def get_value_for_secret(cls, secret, obj=None, **kwargs): # pylint: disable=too-many-locals
"""Get the value for a secret from 1Password."""
# This is only required for 1Password therefore not defined in
# `required_settings` for the app config.
plugin_settings = settings.PLUGINS_CONFIG["nautobot_secrets_providers"]
if "one_password" not in plugin_settings:
raise exceptions.SecretProviderError(secret, cls, "1Password is not configured!")

parameters = secret.rendered_parameters(obj=obj)
vault = parameters["vault"]

return get_secret_from_vault(
vault=vault,
item=parameters["item"],
field=parameters["field"],
token=cls.get_token(secret, vault=vault),
section=parameters.get("section", None),
)
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ <h1>{% block title %}Secrets Providers Home{% endblock %}</h1>
</tr>
</thead>
<tbody>
<tr>
<td><a href="https://1password.com" rel="nofollow">1Password</a></td>
<td><a href="https://1password.com/password-management" rel="nofollow">Hosted Password Management</a></td>
<td><a href="https://developer.1password.com/docs/service-accounts/" rel="nofollow">Service Account Token</a></td>
</tr>
<tr>
<td><a href="https://aws.amazon.com/secrets-manager/" rel="nofollow">AWS Secrets Manager</a></td>
<td><a href="https://docs.aws.amazon.com/secretsmanager/latest/userguide/manage_create-basic-secret.html" rel="nofollow">Other: Key/value pairs</a></td>
Expand Down
109 changes: 109 additions & 0 deletions nautobot_secrets_providers/tests/test_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@
AWSSecretsManagerSecretsProvider,
AWSSystemsManagerParameterStore,
HashiCorpVaultSecretsProvider,
OnePasswordSecretsProvider,
)
from nautobot_secrets_providers.providers.choices import HashicorpKVVersionChoices
from nautobot_secrets_providers.providers.hashicorp import vault_choices
from nautobot_secrets_providers.providers.one_password import vault_choices as one_password_vault_choices

# Use the proper swappable User model
User = get_user_model()
Expand Down Expand Up @@ -709,3 +711,110 @@ def test_retrieve_invalid_version(self):
self.provider.get_value_for_secret(self.secret)
exc = err.exception
self.assertIn("ParameterVersionNotFound", exc.message)


class OnePasswordSecretsProviderTestCase(SecretsProviderTestCase):
"""Tests for OnePasswordSecretsProvider."""

provider = OnePasswordSecretsProvider

def setUp(self):
super().setUp()

# The secret we be using.
self.secret = Secret.objects.create(
name="hello-onepassword",
provider=self.provider.slug,
parameters={
"vault": "example",
"item": "location",
"section": "section",
"field": "value",
},
)
self.secret2 = Secret.objects.create(
name="hello-onepassword-2",
provider=self.provider.slug,
parameters={
"vault": "example_2",
"item": "location",
"field": "value",
},
)

self.plugin_config = {
"nautobot_secrets_providers": {
"one_password": {
"vaults": {
"example": {"token": "nautobot"},
"example_2": {},
},
"token": "another",
}
}
}

@patch("nautobot_secrets_providers.providers.one_password.get_secret_from_vault", return_value="world")
def test_retrieve_success(self, get_secret_from_vault):
"""Retrieve a secret successfully."""
with get_secret_from_vault:
with self.settings(PLUGINS_CONFIG=self.plugin_config):
response = self.provider.get_value_for_secret(self.secret)
self.assertEqual("world", response)
response2 = self.provider.get_value_for_secret(self.secret2)
self.assertEqual("world", response2)

def test_multiple_valid_settings(self):
# Test with a configuration passed in
multiple_plugins_config = {
"nautobot_secrets_providers": {
"one_password": {
"vaults": {
"example": {"token": "nautobot"},
"example_2": {},
},
"token": "another_token",
}
}
}

invalid_plugins_config = {
"nautobot_secrets_providers": {
"one_password": {
"vaults": {
"example": {},
},
}
}
}

with self.settings(PLUGINS_CONFIG=multiple_plugins_config):
token = self.provider.get_token(self.secret, "example")
self.assertEqual(
token,
settings.PLUGINS_CONFIG["nautobot_secrets_providers"]["one_password"]["vaults"]["example"]["token"],
)
token = self.provider.get_token(self.secret, "example_2")
self.assertEqual(
token,
settings.PLUGINS_CONFIG["nautobot_secrets_providers"]["one_password"]["token"],
)

with self.settings(PLUGINS_CONFIG=invalid_plugins_config):
with self.assertRaises(exceptions.SecretProviderError):
self.provider.get_token(self.secret, "example")

def test_vault_choices(self):
multiple_plugins_config = {
"nautobot_secrets_providers": {
"one_password": {
"vaults": {
"Example": {"token": "nautobot"},
"Example 2": {"token": "nautobot"},
}
}
}
}
with self.settings(PLUGINS_CONFIG=multiple_plugins_config):
choices = one_password_vault_choices()
self.assertEqual(choices, [("Example", "Example"), ("Example 2", "Example 2")])
Loading

0 comments on commit 88ad234

Please sign in to comment.