Skip to content

Commit ebc99aa

Browse files
committed
Add HashiCorp Vault Manager
Signed-off-by: Alberto Ferrer Sánchez <alberefe@gmail.com>
1 parent f455a55 commit ebc99aa

File tree

4 files changed

+333
-0
lines changed

4 files changed

+333
-0
lines changed

README.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,80 @@ _NOTE: the parameter "item_name" corresponds with the field "name" of the json.
174174

175175
The module uses the [Bitwarden CLI](https://bitwarden.com/help/cli/) to interact with Bitwarden.
176176

177+
### HashiCorp Vault
178+
179+
```python
180+
from grimoirelab_toolkit.credential_manager.hc_manager import HashicorpManager
181+
182+
183+
# Instantiate the HashiCorp Vault manager using the vault URL and token
184+
# certificate can be a boolean (True/False) or a path to a CA bundle file
185+
hc_manager = HashicorpManager("https://vault.example.com", "your_token", certificate=True)
186+
187+
# Retrieve a secret from HashiCorp Vault
188+
github_secret = hc_manager.get_secret("github")
189+
elasticsearch_secret = hc_manager.get_secret("elasticsearch")
190+
```
191+
192+
#### Response format
193+
194+
When calling `get_secret(item_name)`, the method returns a JSON object with the following structure:
195+
196+
_NOTE: the parameter "item_name" corresponds to the secret path in HashiCorp Vault._
197+
198+
##### Example Response
199+
200+
```json
201+
{
202+
"request_id": "d09e2bb5-00ee-576b-6078-5d291d35ccc3",
203+
"lease_id": "",
204+
"renewable": false,
205+
"lease_duration": 0,
206+
"data": {
207+
"data": {
208+
"username": "test_user",
209+
"password": "test_pass",
210+
"api_key": "test_key"
211+
},
212+
"metadata": {
213+
"created_time": "2024-11-23T12:20:59.985132927Z",
214+
"custom_metadata": null,
215+
"deletion_time": "",
216+
"destroyed": false,
217+
"version": 1
218+
}
219+
},
220+
"wrap_info": null,
221+
"warnings": null,
222+
"auth": null,
223+
"mount_type": "kv"
224+
}
225+
```
226+
227+
Field Descriptions
228+
229+
- request_id: Unique identifier for this Vault request
230+
- lease_id: Lease identifier for renewable secrets (empty for KV secrets)
231+
- renewable: Boolean indicating if the secret is renewable
232+
- lease_duration: Lease duration in seconds (0 for KV secrets)
233+
- data: Main data object containing the secret
234+
- data: The actual secret key-value pairs
235+
- username: Username credential
236+
- password: Password credential
237+
- api_key: API key or other custom fields
238+
- metadata: Vault metadata for this secret
239+
- created_time: Secret creation timestamp (ISO 8601 format)
240+
- custom_metadata: Custom metadata if configured
241+
- deletion_time: Soft deletion timestamp (empty if not deleted)
242+
- destroyed: Boolean indicating if secret version is destroyed
243+
- version: Secret version number
244+
- wrap_info: Response wrapping information (null if not wrapped)
245+
- warnings: Array of warning messages (null if none)
246+
- auth: Authentication information (null for read operations)
247+
- mount_type: Type of secrets engine (typically "kv" for key-value)
248+
249+
The module uses the [hvac](https://hvac.readthedocs.io/) Python library to interact with HashiCorp Vault.
250+
177251
## License
178252

179253
Licensed under GNU General Public License (GPL), version 3 or later.

grimoirelab_toolkit/credential_manager/exceptions.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"InvalidCredentialsError",
2727
"CredentialNotFoundError",
2828
"BitwardenCLIError",
29+
"HashicorpVaultError",
2930
]
3031

3132

@@ -51,3 +52,9 @@ class BitwardenCLIError(CredentialManagerError):
5152
"""Raised for Bitwarden CLI specific errors."""
5253

5354
pass
55+
56+
57+
class HashicorpVaultError(CredentialManagerError):
58+
"""Raised for HashiCorp Vault-specific operation errors."""
59+
60+
pass
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Copyright (C) Grimoirelab Contributors
4+
#
5+
# This program is free software; you can redistribute it and/or modify
6+
# it under the terms of the GNU General Public License as published by
7+
# the Free Software Foundation; either version 3 of the License, or
8+
# (at your option) any later version.
9+
#
10+
# This program is distributed in the hope that it will be useful,
11+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
# GNU General Public License for more details.
14+
#
15+
# You should have received a copy of the GNU General Public License
16+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
17+
#
18+
# Author:
19+
# Alberto Ferrer Sánchez (alberefe@gmail.com)
20+
#
21+
22+
import logging
23+
import hvac
24+
import hvac.exceptions
25+
26+
from .exceptions import (HashicorpVaultError,
27+
CredentialNotFoundError)
28+
29+
logger = logging.getLogger(__name__)
30+
31+
32+
class HashicorpManager:
33+
"""Retrieve credentials from HashiCorp Vault.
34+
35+
This class defines functions to initialize a client and retrieve
36+
secrets from HashiCorp Vault. The workflow is:
37+
38+
manager = HashicorpManager(vault_url, token, certificate)
39+
manager.get_secret("github")
40+
manager.get_secret("elasticsearch")
41+
42+
The manager initializes the client using the vault_url, token,
43+
and certificate given as arguments when creating the instance,
44+
so the object is reusable along the program.
45+
46+
The get_secret function returns the whole item object, with metadata
47+
included, so the user can choose to store it and retrieve desired data.
48+
"""
49+
50+
def __init__(self, vault_url: str, token: str, certificate: str | bool = None):
51+
"""
52+
Creates HashicorpManager object using token authentication
53+
54+
:param str vault_url: The URL of the vault
55+
:param str token: The access token for authentication
56+
:param Union[str, bool, None] certificate: TLS verification setting. Either a boolean to indicate whether TLS
57+
verification should be performed, a string pointing at the CA bundle to use for
58+
verification
59+
60+
:raises ConnectionError: If connection issues occur
61+
"""
62+
try:
63+
logger.debug("Creating Vault client")
64+
# Initialize client with URL, token, and certificate verification setting
65+
self.client = hvac.Client(url=vault_url, token=token, verify=certificate)
66+
logger.debug("Vault client initialized successfully")
67+
except Exception as e:
68+
logger.error("An error occurred initializing the client: %s", str(e))
69+
raise e
70+
71+
def get_secret(self, item_name: str) -> dict:
72+
"""Retrieve an item from the HashiCorp Vault.
73+
74+
Retrieves all the fields stored for an item with the name
75+
provided as an argument and returns them as a dictionary.
76+
77+
The returned dictionary includes fields such as:
78+
- data: The actual secret data and metadata
79+
- request_id, lease_id, renewable, lease_duration
80+
- Other vault metadata
81+
82+
:param str item_name: The name of the item to retrieve
83+
84+
:returns: Dictionary containing the secret data and metadata
85+
:rtype: dict
86+
87+
:raises CredentialNotFoundError: If the secret path is not found
88+
:raises HashicorpVaultError: If Vault operations fail
89+
"""
90+
try:
91+
logger.info("Retrieving credentials from vault: %s", item_name)
92+
# Read secret from KV secrets engine
93+
secret = self.client.secrets.kv.read_secret(path=item_name)
94+
return secret
95+
except hvac.exceptions.InvalidPath:
96+
logger.error("The path %s does not exist in the vault", item_name)
97+
raise CredentialNotFoundError(
98+
f"Secret path '{item_name}' not found in Vault"
99+
)
100+
except Exception as e:
101+
logger.error("Error retrieving the secret: %s", str(e))
102+
raise HashicorpVaultError(f"Vault operation failed: {e}")

tests/test_hc_manager.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Copyright (C) Grimoirelab Contributors
4+
#
5+
# This program is free software; you can redistribute it and/or modify
6+
# it under the terms of the GNU General Public License as published by
7+
# the Free Software Foundation; either version 3 of the License, or
8+
# (at your option) any later version.
9+
#
10+
# This program is distributed in the hope that it will be useful,
11+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
# GNU General Public License for more details.
14+
#
15+
# You should have received a copy of the GNU General Public License
16+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
17+
#
18+
# Author:
19+
# Alberto Ferrer Sánchez (alberefe@gmail.com)
20+
#
21+
22+
import unittest
23+
from unittest.mock import patch
24+
import hvac.exceptions
25+
26+
from grimoirelab_toolkit.credential_manager.hc_manager import HashicorpManager
27+
from grimoirelab_toolkit.credential_manager.exceptions import (
28+
CredentialNotFoundError,
29+
HashicorpVaultError,
30+
)
31+
32+
33+
class TestHashicorpManager(unittest.TestCase):
34+
"""Tests for HashicorpManager class."""
35+
36+
def setUp(self):
37+
"""Set up common test fixtures."""
38+
self.vault_url = "http://vault-url"
39+
self.token = "test-token"
40+
self.certificate = "test-certificate"
41+
42+
self.mock_secret_response = {
43+
"auth": None,
44+
"data": {
45+
"data": {
46+
"password": "test_pass",
47+
"username": "test_user",
48+
"api_key": "test_key",
49+
},
50+
"metadata": {
51+
"created_time": "2024-11-23T12:20:59.985132927Z",
52+
"custom_metadata": None,
53+
"deletion_time": "",
54+
"destroyed": False,
55+
"version": 1,
56+
},
57+
},
58+
"lease_duration": 0,
59+
"lease_id": "",
60+
"mount_type": "kv",
61+
"renewable": False,
62+
"request_id": "d09e2bb5-00ee-576b-6078-5d291d35ccc3",
63+
"warnings": None,
64+
"wrap_info": None,
65+
}
66+
67+
@patch("hvac.Client")
68+
def test_initialization_success(self, mock_hvac_client):
69+
"""Test successful initialization with valid credentials."""
70+
mock_instance = mock_hvac_client.return_value
71+
72+
manager = HashicorpManager(self.vault_url, self.token, self.certificate)
73+
74+
self.assertIsNotNone(manager.client)
75+
mock_hvac_client.assert_called_once_with(
76+
url=self.vault_url, token=self.token, verify=self.certificate
77+
)
78+
79+
@patch("hvac.Client")
80+
def test_initialization_failure(self, mock_hvac_client):
81+
"""Test initialization fails when connection to Vault fails."""
82+
mock_hvac_client.side_effect = hvac.exceptions.VaultError("Connection failed")
83+
84+
with self.assertRaises(hvac.exceptions.VaultError) as context:
85+
HashicorpManager(self.vault_url, self.token, self.certificate)
86+
87+
self.assertIn("Connection failed", str(context.exception))
88+
89+
@patch("hvac.Client")
90+
def test_get_secret_success(self, mock_hvac_client):
91+
"""Test successful secret retrieval."""
92+
mock_instance = mock_hvac_client.return_value
93+
mock_instance.secrets.kv.read_secret.return_value = self.mock_secret_response
94+
95+
manager = HashicorpManager(self.vault_url, self.token, self.certificate)
96+
result = manager.get_secret("test_service")
97+
98+
# Verify it returns the full secret object
99+
self.assertIsInstance(result, dict)
100+
self.assertEqual(result, self.mock_secret_response)
101+
self.assertEqual(result["data"]["data"]["api_key"], "test_key")
102+
mock_instance.secrets.kv.read_secret.assert_called_once_with(
103+
path="test_service"
104+
)
105+
106+
@patch("hvac.Client")
107+
def test_get_secret_not_found(self, mock_hvac_client):
108+
"""Test get_secret raises error when secret path not found."""
109+
mock_instance = mock_hvac_client.return_value
110+
mock_instance.secrets.kv.read_secret.side_effect = hvac.exceptions.InvalidPath()
111+
112+
manager = HashicorpManager(self.vault_url, self.token, self.certificate)
113+
114+
with self.assertRaises(CredentialNotFoundError) as context:
115+
manager.get_secret("nonexistent_service")
116+
117+
self.assertIn("nonexistent_service", str(context.exception))
118+
self.assertIn("not found", str(context.exception))
119+
120+
@patch("hvac.Client")
121+
def test_get_secret_permission_denied(self, mock_hvac_client):
122+
"""Test get_secret raises error when access is forbidden."""
123+
mock_instance = mock_hvac_client.return_value
124+
mock_instance.secrets.kv.read_secret.side_effect = hvac.exceptions.Forbidden()
125+
126+
manager = HashicorpManager(self.vault_url, self.token, self.certificate)
127+
128+
with self.assertRaises(HashicorpVaultError) as context:
129+
manager.get_secret("test_service")
130+
131+
self.assertIn("Vault operation failed", str(context.exception))
132+
133+
@patch("hvac.Client")
134+
def test_vault_connection_error(self, mock_hvac_client):
135+
"""Test get_secret raises error when Vault is down or sealed."""
136+
mock_instance = mock_hvac_client.return_value
137+
mock_instance.secrets.kv.read_secret.side_effect = hvac.exceptions.VaultDown(
138+
"Vault is sealed"
139+
)
140+
141+
manager = HashicorpManager(self.vault_url, self.token, self.certificate)
142+
143+
with self.assertRaises(HashicorpVaultError) as context:
144+
manager.get_secret("test_service")
145+
146+
self.assertIn("Vault operation failed", str(context.exception))
147+
148+
149+
if __name__ == "__main__":
150+
unittest.main(warnings="ignore")

0 commit comments

Comments
 (0)