Skip to content
Open
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
67 changes: 67 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,15 @@ _NOTE: the parameter "item_name" corresponds with the field "name" of the json.
- collectionIds: Array of collection IDs this item belongs to
- attachments: Array of file attachments

#### Session isolation

Each `BitwardenManager` instance creates its own temporary directory and
sets the `BITWARDENCLI_APPDATA_DIR` environment variable to point to it.
This means multiple instances can run in parallel without sharing login
state or session keys. The `HOME` variable is also forwarded so the CLI
can resolve paths correctly. The temporary directory is cleaned up
automatically when `logout()` is called.

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

### HashiCorp Vault
Expand Down Expand Up @@ -259,6 +268,64 @@ Field Descriptions

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

### `resolve_credentials()` — Unified credential resolution

The `CredentialManager` ABC provides a `resolve_credentials()` method that
orchestrates login, secret fetching, field extraction, and logout in a single
call. It is available on any credential manager instance (`BitwardenManager`,
`HashicorpManager`).

This method is designed to work independently of any CLI framework, making
it usable from Perceval's `BackendCommand`, KingArthur, or any custom script.

#### Example

```python
from grimoirelab_toolkit.credential_manager import BitwardenManager
from grimoirelab_toolkit.credential_manager.hc_manager import HashicorpManager

# Bitwarden example
bw_manager = BitwardenManager("your-client-id", "your-client-secret", "your-master-password")
credentials = bw_manager.resolve_credentials("GitHub", ["api_token", "user"])
# credentials = {'api_token': 'ghp_...', 'user': 'myuser'}

# HashiCorp example
hc_manager = HashicorpManager("https://vault.example.com", "hvs.your-token")
credentials = hc_manager.resolve_credentials("secret/my-service", ["api_token"])
```

#### Parameters

- `item_name` (`str`) — Name/path of the secret item in the vault
- `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.

#### Return value

A `dict[str, str]` mapping field names to resolved string values.
Only fields that were found are included in the result. Missing fields
produce a warning log and are omitted (partial results are valid).

#### Field extraction behavior

Each manager returns secrets in a different format. `resolve_credentials()`
normalizes the extraction via each manager's `extract_field()` implementation:

- **Bitwarden**: Checks `item['login']` dict first (for username, password),
then searches the `item['fields']` array (for custom fields like API tokens).
- **HashiCorp**: Reads from `secret['data']['data'][field_name]`.

#### Error handling

- Empty `item_name` raises `ValueError`
- Secret item not found raises `CredentialNotFoundError`
- Individual missing fields are skipped with a warning (no error)
- `logout()` is always called in a `finally` block

### Optional dependencies

- **Bitwarden**: requires `bw` CLI on `PATH` (no Python package needed)
- **HashiCorp**: requires `hvac` (`poetry install --with hashicorp-manager`)

## License

Licensed under GNU General Public License (GPL), version 3 or later.
8 changes: 5 additions & 3 deletions grimoirelab_toolkit/credential_manager/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,24 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# Author:
# Alberto Ferrer Sánchez (alberefe@gmail.com)
#


from .bw_manager import BitwardenManager
from .credential_manager import CredentialManager
from .exceptions import (
CredentialManagerError,
InvalidCredentialsError,
CredentialNotFoundError,
BitwardenCLIError,
HashicorpVaultError,
)

__all__ = [
"CredentialManager",
"BitwardenManager",
"CredentialManagerError",
"InvalidCredentialsError",
"CredentialNotFoundError",
"BitwardenCLIError",
"HashicorpVaultError",
]
54 changes: 47 additions & 7 deletions grimoirelab_toolkit/credential_manager/bw_manager.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
#
#
# Copyright (C) Grimoirelab Contributors
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
Expand All @@ -14,14 +15,16 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# Author:
# Alberto Ferrer Sánchez (alberefe@gmail.com)
#


import json
import os
import subprocess
import logging
import shutil
import tempfile

from .credential_manager import CredentialManager
from .exceptions import (
BitwardenCLIError,
InvalidCredentialsError,
Expand All @@ -31,7 +34,7 @@
logger = logging.getLogger(__name__)


class BitwardenManager:
class BitwardenManager(CredentialManager):
"""Retrieve credentials from Bitwarden.

This class defines functions to log in, retrieve secrets
Expand All @@ -48,6 +51,13 @@ class BitwardenManager:
master_password given as arguments when creating the instance,
so the object is reusable along the program.

Each instance creates an isolated temporary directory used as
``BITWARDENCLI_APPDATA_DIR``, so multiple instances can run in
parallel without sharing session state. The ``HOME`` environment
variable is also forwarded so that the Bitwarden CLI can locate
its configuration. The temporary directory is cleaned up
automatically when manager.logout is called.

The path of Bitwarden CLI (bw) is retrieved using shutil.
"""

Expand All @@ -72,11 +82,15 @@ def __init__(self, client_id: str, client_secret: str, master_password: str):
if not self.bw_path:
raise BitwardenCLIError("Bitwarden CLI (bw) not found in PATH")

# Set up environment variables for consistent execution context
# Each instance gets its own appdata dir so parallel sessions
# don't share state and bw can find its config directory.
self._appdata_dir = tempfile.mkdtemp(prefix="bw_session_")
self.env = {
"HOME": os.path.expanduser("~"),
"LANG": "C",
"BW_CLIENTID": client_id,
"BW_CLIENTSECRET": client_secret,
"BITWARDENCLI_APPDATA_DIR": self._appdata_dir,
}

def login(self) -> str | None:
Expand Down Expand Up @@ -190,7 +204,9 @@ def get_secret(self, item_name: str) -> dict:
def logout(self) -> None:
"""Log out from Bitwarden and invalidate the session.

This method ends the current session and clears the session key.
This method ends the current session, clears the session key,
and removes the temporary appdata directory created during
initialization to avoid leaving stale session data on disk.
"""
logger.info("Logging out from Bitwarden")

Expand All @@ -209,4 +225,28 @@ def logout(self) -> None:
# Clear session key for security
self.session_key = None

# Remove the temporary appdata directory
shutil.rmtree(self._appdata_dir, ignore_errors=True)

logger.info("Successfully logged out from Bitwarden")

def extract_field(self, secret: dict, field_name: str) -> str | None:
"""Extract a field value from a Bitwarden item.

Searches in the 'login' dict first, then in the 'fields' array.

:param dict secret: The Bitwarden item dictionary
:param str field_name: The name of the field to extract

:returns: The field value or None if not found
:rtype: str or None
"""
login_data = secret.get('login', {})
if login_data and field_name in login_data:
return login_data[field_name]

for field in secret.get('fields', []):
if field.get('name') == field_name:
return field.get('value')

return None
112 changes: 112 additions & 0 deletions grimoirelab_toolkit/credential_manager/credential_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) Grimoirelab Contributors
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#


import logging

from abc import ABC, abstractmethod

logger = logging.getLogger(__name__)


class CredentialManager(ABC):
"""Abstract base class for credential managers.

Defines the interface that all credential manager implementations
must follow. Provides a concrete `resolve_credentials` method
(Template Method) that orchestrates the shared workflow of
logging in, fetching a secret, extracting fields, and logging out.

Subclasses must implement `get_secret` and `extract_field`.
Subclasses that require authentication (e.g. Bitwarden) should
override `login` and `logout`.
"""

@abstractmethod
def get_secret(self, item_name: str) -> dict:
"""Retrieve a secret item from the vault.

:param str item_name: The name/path of the secret item

:returns: Dictionary containing the secret data
:rtype: dict
"""
raise NotImplementedError

@abstractmethod
def extract_field(self, secret: dict, field_name: str) -> str | None:
"""Extract a single field value from a secret item.

:param dict secret: The secret dictionary returned by `get_secret`
:param str field_name: The name of the field to extract

:returns: The field value, or None if not found
:rtype: str or None
"""
raise NotImplementedError

def login(self):
"""Log into the secrets manager. No-op by default."""
pass

def logout(self):
"""Log out from the secrets manager. No-op by default."""
pass

def resolve_credentials(
self,
item_name: str,
field_names: list[str],
) -> dict[str, str]:
"""Resolve credentials from the secrets manager.

Fetches a secret item and extracts the requested fields.
Handles login/logout lifecycle automatically.

:param str item_name: Name/path of the secret item in the vault
:param list field_names: List of field names to extract

:returns: Dict mapping field names to their resolved values.
Only fields that were found are included.
:rtype: dict[str, str]

:raises ValueError: If item_name is empty
"""
if not item_name:
raise ValueError("Missing item name")

if not field_names:
return {}

self.login()
try:
secret = self.get_secret(item_name)
result = {}
for field_name in field_names:
value = self.extract_field(secret, field_name)
if value is None:
logger.warning(
"Field '%s' not found in secret '%s'",
field_name, item_name,
)
continue
result[field_name] = value
finally:
self.logout()

return result
4 changes: 1 addition & 3 deletions grimoirelab_toolkit/credential_manager/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# Author:
# Alberto Ferrer Sánchez (alberefe@gmail.com)
#


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

Expand Down
Loading
Loading