Skip to content

Commit a91b6fb

Browse files
committed
Add resolve_credentials() for standalone credential resolution
Add a framework-agnostic resolve_credentials() function that fetches and extracts credentials from Bitwarden or HashiCorp Vault in a single call. This allows any caller (BackendCommand, KingArthur, scripts) to resolve secrets without depending on Perceval's CLI layer. - Add resolve_credentials() with field extraction helpers - Add SecretsManagerFactory for lazy manager instantiation - Export resolve_credentials and HashicorpVaultError from package - Add tests for all credential resolution paths - Document usage in README Signed-off-by: Alberto Ferrer Sánchez <alberefe@gmail.com>
1 parent efb1002 commit a91b6fb

8 files changed

Lines changed: 614 additions & 9 deletions

File tree

README.md

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,89 @@ Field Descriptions
259259

260260
The module uses the [hvac](https://hvac.readthedocs.io/) Python library to interact with HashiCorp Vault.
261261

262+
### `resolve_credentials()` — Unified credential resolution
263+
264+
The `resolve_credentials()` function provides a high-level interface to
265+
fetch and extract credentials from any supported secrets manager in a single
266+
call. It handles manager instantiation, authentication, secret retrieval,
267+
field extraction, and cleanup automatically.
268+
269+
This function is designed to work independently of any CLI framework, making
270+
it usable from Perceval's `BackendCommand`, KingArthur, or any custom script.
271+
272+
#### Example
273+
274+
```python
275+
from grimoirelab_toolkit.credential_manager import resolve_credentials
276+
277+
# Bitwarden example
278+
credentials = resolve_credentials(
279+
manager_type='bitwarden',
280+
manager_config={
281+
'client_id': 'your-client-id',
282+
'client_secret': 'your-client-secret',
283+
'master_password': 'your-master-password',
284+
},
285+
item_name='GitHub',
286+
field_names=['api_token', 'user'],
287+
)
288+
# credentials = {'api_token': 'ghp_...', 'user': 'myuser'}
289+
290+
# HashiCorp example
291+
credentials = resolve_credentials(
292+
manager_type='hashicorp',
293+
manager_config={
294+
'vault_url': 'https://vault.example.com',
295+
'token': 'hvs.your-token',
296+
},
297+
item_name='secret/my-service',
298+
field_names=['api_token'],
299+
)
300+
```
301+
302+
#### Parameters
303+
304+
- `manager_type` (`str`) — `"bitwarden"` or `"hashicorp"`
305+
- `manager_config` (`dict`) — Manager-specific authentication credentials (see below)
306+
- `item_name` (`str`) — Name/path of the secret item in the vault
307+
- `field_names` (`list[str]`) — List of field names to look up in the vault. Field names must match the names used when storing the secret.
308+
309+
#### Manager config by provider
310+
311+
- **Bitwarden**: `client_id`, `client_secret`, `master_password`
312+
- **HashiCorp**: `vault_url`, `token`, `certificate` (optional)
313+
314+
#### Return value
315+
316+
A `dict[str, str]` mapping field names to resolved string values.
317+
Only fields that were found are included in the result. Missing fields
318+
produce a warning log and are omitted (partial results are valid).
319+
320+
#### Field extraction behavior
321+
322+
Each manager returns secrets in a different format. `resolve_credentials()`
323+
normalizes the extraction:
324+
325+
- **Bitwarden**: Checks `item['login']` dict first (for username, password),
326+
then searches the `item['fields']` array (for custom fields like API tokens).
327+
- **HashiCorp**: Reads from `secret['data']['data'][field_name]`.
328+
329+
#### Error handling
330+
331+
- Unsupported `manager_type` raises `ValueError`
332+
- Empty `item_name` raises `ValueError`
333+
- Secret item not found raises `CredentialNotFoundError`
334+
- Individual missing fields are skipped with a warning (no error)
335+
- For Bitwarden, `logout()` is always called in a `finally` block
336+
337+
### Optional dependencies (lazy imports)
338+
339+
The secrets manager providers use lazy imports, so you only need to install
340+
the dependencies for the provider you actually use:
341+
342+
- **Bitwarden**: requires `bw` CLI on `PATH` (no Python package needed)
343+
- **HashiCorp**: requires `hvac` (`poetry install --with hashicorp-manager`)
344+
262345
## License
263346

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

grimoirelab_toolkit/credential_manager/__init__.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,16 @@
1515
# You should have received a copy of the GNU General Public License
1616
# along with this program. If not, see <http://www.gnu.org/licenses/>.
1717
#
18-
# Author:
19-
# Alberto Ferrer Sánchez (alberefe@gmail.com)
20-
#
18+
2119

2220
from .bw_manager import BitwardenManager
21+
from .credential_manager import resolve_credentials
2322
from .exceptions import (
2423
CredentialManagerError,
2524
InvalidCredentialsError,
2625
CredentialNotFoundError,
2726
BitwardenCLIError,
27+
HashicorpVaultError,
2828
)
2929

3030
__all__ = [
@@ -33,4 +33,6 @@
3333
"InvalidCredentialsError",
3434
"CredentialNotFoundError",
3535
"BitwardenCLIError",
36+
"HashicorpVaultError",
37+
"resolve_credentials",
3638
]

grimoirelab_toolkit/credential_manager/bw_manager.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,8 @@
1414
# You should have received a copy of the GNU General Public License
1515
# along with this program. If not, see <http://www.gnu.org/licenses/>.
1616
#
17-
# Author:
18-
# Alberto Ferrer Sánchez (alberefe@gmail.com)
19-
#
17+
18+
2019
import json
2120
import subprocess
2221
import logging
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
#
2+
#
3+
#
4+
# This program is free software; you can redistribute it and/or modify
5+
# it under the terms of the GNU General Public License as published by
6+
# the Free Software Foundation; either version 3 of the License, or
7+
# (at your option) any later version.
8+
#
9+
# This program is distributed in the hope that it will be useful,
10+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
# GNU General Public License for more details.
13+
#
14+
# You should have received a copy of the GNU General Public License
15+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
16+
#
17+
18+
19+
import logging
20+
21+
from .secrets_manager_factory import SecretsManagerFactory
22+
23+
logger = logging.getLogger(__name__)
24+
25+
SUPPORTED_MANAGERS = ("bitwarden", "hashicorp")
26+
27+
28+
def _extract_bitwarden_field(item, field_name):
29+
"""Extract a field value from a Bitwarden item.
30+
31+
Searches in the 'login' dict first, then in the 'fields' array.
32+
33+
:param dict item: The Bitwarden item dictionary
34+
:param str field_name: The name of the field to extract
35+
36+
:returns: The field value or None if not found
37+
:rtype: str or None
38+
"""
39+
# Check login fields first (username, password, etc.)
40+
login_data = item.get('login', {})
41+
if login_data and field_name in login_data:
42+
return login_data[field_name]
43+
44+
# Check custom fields array
45+
for field in item.get('fields', []):
46+
if field.get('name') == field_name:
47+
return field.get('value')
48+
49+
return None
50+
51+
52+
def _extract_hashicorp_field(secret, field_name):
53+
"""Extract a field value from a HashiCorp Vault secret.
54+
55+
Reads from secret['data']['data'][field_name].
56+
57+
:param dict secret: The HashiCorp Vault secret dictionary
58+
:param str field_name: The name of the field to extract
59+
60+
:returns: The field value or None if not found
61+
:rtype: str or None
62+
"""
63+
secret_data = secret.get('data', {}).get('data', {})
64+
return secret_data.get(field_name)
65+
66+
67+
def resolve_credentials(
68+
manager_type: str,
69+
manager_config: dict,
70+
item_name: str,
71+
field_names: list[str],
72+
) -> dict[str, str]:
73+
"""Resolve credentials from a secrets manager.
74+
75+
Fetches a secret item from the specified secrets manager and extracts
76+
the requested fields. Field names in the vault must match the parameter
77+
names expected by Perceval (e.g. 'api_token', 'user', 'password').
78+
79+
:param str manager_type: The secrets manager to use
80+
('bitwarden' or 'hashicorp')
81+
:param dict manager_config: Manager-specific authentication config.
82+
Bitwarden: {'client_id', 'client_secret', 'master_password'}
83+
HashiCorp: {'vault_url', 'token', 'certificate'}
84+
:param str item_name: Name/path of the secret item in the vault
85+
:param list field_names: List of field names to look up in the vault.
86+
Example: ['api_token', 'user', 'password']
87+
88+
:returns: Dict mapping field names to resolved string values.
89+
Only fields that were found are included.
90+
:rtype: dict[str, str]
91+
92+
:raises ValueError: If manager_type is unsupported or item_name is empty
93+
:raises CredentialNotFoundError: If the secret item is not found
94+
:raises CredentialManagerError: If manager authentication fails
95+
"""
96+
if manager_type not in SUPPORTED_MANAGERS:
97+
raise ValueError(
98+
f"Unsupported secrets manager: '{manager_type}'. "
99+
f"Supported: {', '.join(SUPPORTED_MANAGERS)}"
100+
)
101+
102+
if not item_name:
103+
raise ValueError("item_name must be non-empty")
104+
105+
if not field_names:
106+
return {}
107+
108+
result = {}
109+
110+
if manager_type == 'bitwarden':
111+
manager = SecretsManagerFactory.get_bitwarden_manager(
112+
manager_config['client_id'],
113+
manager_config['client_secret'],
114+
manager_config['master_password'],
115+
)
116+
manager.login()
117+
try:
118+
item = manager.get_secret(item_name)
119+
for field_name in field_names:
120+
value = _extract_bitwarden_field(item, field_name)
121+
if value is None:
122+
logger.warning(
123+
"Field '%s' not found in Bitwarden item '%s'",
124+
field_name, item_name,
125+
)
126+
continue
127+
result[field_name] = value
128+
finally:
129+
manager.logout()
130+
131+
elif manager_type == 'hashicorp':
132+
manager = SecretsManagerFactory.get_hashicorp_manager(
133+
manager_config['vault_url'],
134+
manager_config['token'],
135+
manager_config.get('certificate'),
136+
)
137+
secret = manager.get_secret(item_name)
138+
for field_name in field_names:
139+
value = _extract_hashicorp_field(secret, field_name)
140+
if value is None:
141+
logger.warning(
142+
"Field '%s' not found in HashiCorp secret '%s'",
143+
field_name, item_name,
144+
)
145+
continue
146+
result[field_name] = value
147+
148+
return result

grimoirelab_toolkit/credential_manager/exceptions.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,7 @@
1515
# You should have received a copy of the GNU General Public License
1616
# along with this program. If not, see <http://www.gnu.org/licenses/>.
1717
#
18-
# Author:
19-
# Alberto Ferrer Sánchez (alberefe@gmail.com)
20-
#
18+
2119

2220
"""Custom exceptions for the credential manager module."""
2321

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
#
2+
#
3+
#
4+
# This program is free software; you can redistribute it and/or modify
5+
# it under the terms of the GNU General Public License as published by
6+
# the Free Software Foundation; either version 3 of the License, or
7+
# (at your option) any later version.
8+
#
9+
# This program is distributed in the hope that it will be useful,
10+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
# GNU General Public License for more details.
13+
#
14+
# You should have received a copy of the GNU General Public License
15+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
16+
#
17+
18+
19+
import logging
20+
21+
from .exceptions import HashicorpVaultError
22+
23+
logger = logging.getLogger(__name__)
24+
25+
26+
class SecretsManagerFactory:
27+
"""Factory for creating secrets manager instances.
28+
29+
This class defines static methods to create instances of different
30+
secrets manager implementations. The workflow is:
31+
32+
bw_manager = SecretsManagerFactory.get_bitwarden_manager(client_id, client_secret, master_password)
33+
hc_manager = SecretsManagerFactory.get_hashicorp_manager(vault_url, token, certificate)
34+
35+
Each factory method handles the instantiation of the appropriate
36+
manager class with the required credentials and configuration,
37+
providing a centralized way to create secrets manager objects.
38+
39+
The factory validates required parameters before creating instances
40+
and raises appropriate exceptions if credentials are missing or invalid.
41+
"""
42+
43+
@staticmethod
44+
def get_bitwarden_manager(client_id: str, client_secret: str, master_password: str):
45+
"""Create a BitwardenManager instance.
46+
47+
Creates and returns a new BitwardenManager instance using the
48+
provided API credentials and master password.
49+
50+
:param str client_id: Bitwarden API client ID
51+
:param str client_secret: Bitwarden API client secret
52+
:param str master_password: Master password for unlocking the vault
53+
54+
:returns: A new BitwardenManager instance
55+
:rtype: BitwardenManager
56+
57+
:raises BitwardenCLIError: If Bitwarden CLI is not found in PATH
58+
"""
59+
from .bw_manager import BitwardenManager
60+
61+
logger.debug("Creating new Bitwarden manager")
62+
63+
return BitwardenManager(client_id, client_secret, master_password)
64+
65+
@staticmethod
66+
def get_hashicorp_manager(
67+
vault_url: str, token: str, certificate: str | bool = None
68+
):
69+
"""Create a HashicorpManager instance.
70+
71+
Creates and returns a new HashicorpManager instance using the
72+
provided Vault URL, authentication token, and certificate settings.
73+
74+
:param str vault_url: The URL of the HashiCorp Vault
75+
:param str token: The access token for authentication
76+
:param Union[str, bool, None] certificate: TLS verification setting. Either a boolean to indicate whether TLS
77+
verification should be performed, a string pointing at the CA bundle to use for
78+
verification
79+
80+
:returns: A new HashicorpManager instance
81+
:rtype: HashicorpManager
82+
83+
:raises HashicorpVaultError: If required credentials cannot be obtained
84+
"""
85+
from .hc_manager import HashicorpManager
86+
87+
logger.debug("Creating new Hashicorp manager")
88+
89+
if not all([vault_url, token]):
90+
raise HashicorpVaultError("HashiCorp Vault requires vault_url and token")
91+
92+
return HashicorpManager(vault_url, token, certificate)
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
title: 'Add standalone credential resolution function'
2+
category: added
3+
author: Alberto Ferrer Sánchez <alberefe@gmail.com>
4+
issue:
5+
notes: >
6+
Add resolve_credentials() function that allows resolving credentials
7+
from configuration without requiring the full GrimoireLab framework.

0 commit comments

Comments
 (0)