Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Keeper Secrets Manager provider #122

Draft
wants to merge 4 commits into
base: develop
Choose a base branch
from
Draft
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
44 changes: 44 additions & 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:
| [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)<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/> |
| [Keeper Secret Manager](https://docs.keeper.io/secrets-manager/secrets-manager/quick-start-guide) | [Other: Key/value pairs](https://docs.keeper.io/secrets-manager/secrets-manager/developer-sdk-library/python-sdk#retrieve-secrets)| [One Time Access Token](https://docs.keeper.io/secrets-manager/secrets-manager/about/one-time-token) |

## Screenshots

Expand Down Expand Up @@ -52,6 +53,49 @@ This app supports the following popular secrets backends:

See the [installation documentation](https://docs.nautobot.com/projects/secrets-providers/en/latest/admin/install/) for detailed instructions on installing the Nautobot Secrets Providers app.

### Keeper Secret Manager

The Keeper Secret Manager plugin includes two providers:

- **`Keeper by UID`**

This provider uses the `UID` to specify the secret that is retrieved. The `UID` is displayed in the `Record Info`.

- Example:

The url is: _https://keepersecurity.com/vault/#detail/OuiSc5IkgxEBnbnSyVJ5UA_

In this example the value for `UID` is **OuiSc5IkgxEBnbnSyVJ5UA**.

- **`Keeper by Name/Title`**

This provider allows to select the secret by name/title.

- Example:

The url is _https://keepersecurity.com/vault/#search/XXX-SW-XXX-F0_

In this example the value for `Title` is **XXX-SW-XXX-F0**.

#### Configuration

```python
PLUGINS_CONFIG = {
"nautobot_secrets_providers": {
"keeper": { # https://github.com/Keeper-Security/secrets-manager/tree/master/sdk/python
"token": os.getenv("KEEPER_TOKEN", None),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given the listed required/optional keys below, should type be added to this example and token removed?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type is per secret, Token is more "global"

},
}
}
```
- `name` - (optional with uid) The secret name/title. _e.g.'XXX-SW-XXX-F0'_
- `uid` - (optional with name) The secret uid. _e.g.'OuiSc5IkgxEBnbnSyVJ5UA'_
- `token` - (optional with config) The Keeper Secret Manager token. _e.g.'1234'_
- `config` - (optional with token) The Keeper Secret Manager config. _e.g.'JSON'_
- `type` - (required) The info type to retrieve from the secret (ENUM CHOICE `password` / `username`). _e.g.'password'_

Either one of `uid` or `name` must be specified. If `token` is not specified, it will use JSON config specified either in the form or in config file instead. If none of them are specified, it will fail to connect to Keeper Secret Manager.

## Contributing

Pull requests are welcomed and automatically built and tested against multiple version of Python and multiple version of Nautobot through GitHub Actions.
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 @@ -3,11 +3,13 @@
from .aws import AWSSecretsManagerSecretsProvider, AWSSystemsManagerParameterStore
from .hashicorp import HashiCorpVaultSecretsProvider
from .delinea import ThycoticSecretServerSecretsProviderId, ThycoticSecretServerSecretsProviderPath
from .keeper import KeeperSecretsProvider

__all__ = ( # type: ignore
AWSSecretsManagerSecretsProvider, # pylint: disable=invalid-all-object
AWSSystemsManagerParameterStore, # pylint: disable=invalid-all-object
HashiCorpVaultSecretsProvider, # pylint: disable=invalid-all-object
ThycoticSecretServerSecretsProviderId, # pylint: disable=invalid-all-object
ThycoticSecretServerSecretsProviderPath, # pylint: disable=invalid-all-object
KeeperSecretsProvider, # pylint: disable=invalid-all-object
)
14 changes: 13 additions & 1 deletion nautobot_secrets_providers/providers/choices.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Choices for Thycotic Secret Server Plugin."""
"""Choices for providers."""
from nautobot.core.choices import ChoiceSet


Expand Down Expand Up @@ -30,3 +30,15 @@ class HashicorpKVVersionChoices(ChoiceSet):
(KV_VERSION_1, "V1"),
(KV_VERSION_2, "V2"),
)


class KeeperTypeChoices(ChoiceSet):
"""Choices for Keeper type."""

PASSWORD = "password"
USERNAME = "username"

CHOICES = (
(PASSWORD, "password"), # nosec B105
(USERNAME, "username"),
)
196 changes: 196 additions & 0 deletions nautobot_secrets_providers/providers/keeper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
"""Secrets Provider for Keeper."""
import os

# from pathlib import Path
# import base64
# import json

try:
from keeper_secrets_manager_core import SecretsManager
from keeper_secrets_manager_core.core import KSMCache
from keeper_secrets_manager_core.exceptions import KeeperError, KeeperAccessDenied
from keeper_secrets_manager_core.storage import FileKeyValueStorage # , InMemoryKeyValueStorage

# from keeper_secrets_manager_core.utils import get_totp_code
except (ImportError, ModuleNotFoundError):
keeper = None

from django import forms
from django.conf import settings

# from django.core.exceptions import ValidationError

from nautobot.apps.secrets import exceptions, SecretsProvider
from nautobot.utilities.forms import BootstrapMixin

from .choices import KeeperTypeChoices


__all__ = ("KeeperSecretsProvider",)


try:
plugins_config = settings.PLUGINS_CONFIG["nautobot_secrets_providers"]
KEEPER_TOKEN = plugins_config["keeper"]["token"]
except KeyError:
KEEPER_TOKEN = None


class KeeperSecretsProvider(SecretsProvider):
"""A secrets provider for Keeper Secrets Manager."""

slug = "keeper-secret-manager"
name = "Keeper Secret Manager"

class ParametersForm(BootstrapMixin, forms.Form):
"""Parameters for Keeper Secrets Manager."""

name = forms.CharField(
label="Secret Name",
help_text="The secret's name",
max_length=30,
min_length=5,
)
uid = forms.CharField(
label="Secret UID",
help_text="The secret's uid",
max_length=25,
min_length=20,
)
token = forms.CharField(
label="Token",
widget=forms.PasswordInput,
help_text="The One Time Token",
max_length=40,
min_length=20,
initial=KEEPER_TOKEN,
)
Comment on lines +60 to +67
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm hesitant to have this as a user-specified form field since its value would be user-readable and stored in the DB. Isn't the token a sensitive value?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with you wit traditional token
With Keeper Secrets Manager it's a One Time Token that will generate the config file config.json that will be used afterwards
I'm still unsure of the best way to process this, so I implement both way of providing connection, either with the config in JSON or BASE64 (not implemented yet as seen on #L156 or with the Token that will be valid once only

"""
https://docs.keeper.io/secrets-manager/secrets-manager/developer-sdk-library
{
"hostname": "keepersecurity.com",
"clientId": "ab2x3z/Acz0QFTiilm8UxIlqNLlNa25KMj=TpOqznwa4Si-h9tY7n3zvFwlXXDoVWkIs3xrMjcLGwgu3ilmq7Q==",
"privateKey": "MLSHAgABCDEFGyqGSM49AEGCCqGSM49AwEHBG0wawIWALTARgmcnWx/DH+r7cKh4kokasdasdaDbvHmLABstNbqDwaCWhRANCAARjunta9SJdZE/LVXfVb22lpIfK4YMkJEDaFMOAyoBt0BrQ8aEhvrHN5/Z1BgZ/WpDm9dMR7E5ASIQuYUiAw0t9",
"serverPublicKeyId": "10",
"appKey": "RzhSIyKxbpjNu045TUrKaNREYIns+Hk9Kn8YtT+CtK0=",
"appOwnerPublicKey": "Sq1W1OAnTwi8V/Vs/lhsin2sfSoaRfOwwDDBqoP+EO9bsBMWCzQdl9ClauDiKLXGmlmyx2xmSAdH+hlxvBRs6kU="
}
"""
config = forms.JSONField(
label="Config",
help_text="The JSON configuration",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any chance of giving an example of the expected structure of this field?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://docs.keeper.io/secrets-manager/secrets-manager/developer-sdk-library

        {
            "hostname": "keepersecurity.com",
            "clientId": "ab2x3z/Acz0QFTiilm8UxIlqNLlNa25KMj=TpOqznwa4Si-h9tY7n3zvFwlXXDoVWkIs3xrMjcLGwgu3ilmq7Q==",
            "privateKey": "MLSHAgABCDEFGyqGSM49AEGCCqGSM49AwEHBG0wawIWALTARgmcnWx/DH+r7cKh4kokasdasdaDbvHmLABstNbqDwaCWhRANCAARjunta9SJdZE/LVXfVb22lpIfK4YMkJEDaFMOAyoBt0BrQ8aEhvrHN5/Z1BgZ/WpDm9dMR7E5ASIQuYUiAw0t9",
            "serverPublicKeyId": "10",
            "appKey": "RzhSIyKxbpjNu045TUrKaNREYIns+Hk9Kn8YtT+CtK0=",
            "appOwnerPublicKey": "Sq1W1OAnTwi8V/Vs/lhsin2sfSoaRfOwwDDBqoP+EO9bsBMWCzQdl9ClauDiKLXGmlmyx2xmSAdH+hlxvBRs6kU="
        }

How should I implement it in the code as an helper ?

max_length=500,
min_length=70,
)
# config = forms.CharField(
# required=True,
# help_text="The base64 configuration",
# max_length=300,
# min_length=30,
# )
type = forms.ChoiceField(
label="Type",
required=True,
choices=KeeperTypeChoices.CHOICES,
help_text="The type of information to retrieve from the secret/record",
)

"""
Overloaded clean method to check that at least one of the secret's name or uid is provided
"""

def clean(self):
cleaned_data = super().clean()
if not cleaned_data.get("name") and not cleaned_data.get("uid"):
raise forms.ValidationError("At least the secret's name or uid must be provided")
if cleaned_data.get("name") and cleaned_data.get("uid"):
raise forms.ValidationError("Only one of the secret's name or uid must be provided")
if not cleaned_data.get("token") and not cleaned_data.get("config"):
raise forms.ValidationError("At least the token or config must be provided")
return cleaned_data

@classmethod
def get_value_for_secret(cls, secret, obj=None, **kwargs):
"""Return the secret value."""
# Extract the parameters from the Secret.

parameters = secret.rendered_parameters(obj=obj)

if keeper is None:
raise exceptions.SecretProviderError(
secret, cls, "The Python dependency keeper_secrets_manager_core is not installed"
)

try:
if "name" in parameters:
secret_name = parameters["name"]
if "uid" in parameters:
secret_uid = parameters["uid"]
token = parameters.get("token", KEEPER_TOKEN)
if "config" in parameters:
config = parameters["config"]
type = parameters.get("type")
except KeyError as err:
msg = f"The secret parameter could not be retrieved for field {err}"
raise exceptions.SecretParametersError(secret, cls, msg) from err

if not KEEPER_TOKEN and not token and not config:
raise exceptions.SecretProviderError(
secret, cls, "Nor the Token or config is configured, at least 1 is required!"
)

if not secret_name and not secret_uid:
raise exceptions.SecretProviderError(secret, cls, "At least the secret's name or uid must be provided!")

# Ensure required parameters are set
if any([not all([secret_name, secret_uid, token, config, type])]):
raise exceptions.SecretProviderError(
secret,
"Keeper Secret Manager is not configured!",
)

try:
# Create a Secrets Manager client.
secrets_manager = SecretsManager(
token=token,
# config=InMemoryKeyValueStorage(config),
config=FileKeyValueStorage("config.json"),
log_level="DEBUG" if os.environ.get("DEBUG", None) else "ERROR",
custom_post_function=KSMCache.caching_post_function,
)
except (KeeperError, KeeperAccessDenied) as err:
msg = f"Unable to connect to Keeper Secret Manager {err}"
raise exceptions.SecretProviderError(secret, msg) from err
except Exception as err:
msg = f"Unable to connect to Keeper Secret Manager {err}"
raise exceptions.SecretProviderError(secret, msg) from err

if secret_uid:
try:
secret = secrets_manager.get_secrets(uids=secret_uid)[0]
# # https://docs.keeper.io/secrets-manager/secrets-manager/about/keeper-notation
# secret = secrets_manager.get_notation(f'{secret_uid}/field/{type}')[0]
except Exception as err:
msg = f"The secret could not be retrieved using uid {err}"
raise exceptions.SecretValueNotFoundError(secret, cls, msg) from err
elif secret_name:
try:
secret = secrets_manager.get_secret_by_title(secret_name)
except Exception as err:
msg = f"The secret could not be retrieved using name {err}"
raise exceptions.SecretValueNotFoundError(secret, cls, msg) from err
else:
msg = f"At least the secret's name or uid must be provided"
raise exceptions.SecretValueNotFoundError(secret, cls, msg)

try:
my_secret_info = secret.field(type, single=True)
# api_key = secret.custom_field('API Key', single=True)
# url = secret.get_standard_field_value('oneTimeCode', True)
# totp = get_totp_code(url)
# https://github.com/Keeper-Security/secrets-manager/blob/master/sdk/python/core/keeper_secrets_manager_core/utils.py#L124C24-L124C24:
except Exception as err:
msg = f"The secret field could not be retrieved {err}"
raise exceptions.SecretValueNotFoundError(secret, cls, msg) from err

return my_secret_info
37 changes: 36 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ hvac = { version = ">=0.11.0, <1.1.0", optional = true }
nautobot = "^2.0.0"
python = ">=3.8,<3.12"
python-tss-sdk = {version = "~1.2.0", optional = true}
keeper-secrets-manager-core = {version = "~16", optional = true}

[tool.poetry.group.dev.dependencies]
Markdown = "*"
Expand Down Expand Up @@ -69,11 +70,13 @@ all = [
"boto3",
"hvac",
"python-tss-sdk",
"keeper-secrets-manager-core",
]
aws = ["boto3"]
hashicorp = ["hvac"]
nautobot = ["nautobot"]
thycotic = ["python-tss-sdk"]
keeper = ["keeper-secrets-manager-core"]
EdificomSA marked this conversation as resolved.
Show resolved Hide resolved

[tool.black]
line-length = 120
Expand Down