bt-cred-resolver 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bt_cred_resolver-0.1.0/PKG-INFO +11 -0
- bt_cred_resolver-0.1.0/README.md +61 -0
- bt_cred_resolver-0.1.0/bt_cred_resolver/__init__.py +60 -0
- bt_cred_resolver-0.1.0/bt_cred_resolver/credential_resolver.py +260 -0
- bt_cred_resolver-0.1.0/bt_cred_resolver/password_manager.py +327 -0
- bt_cred_resolver-0.1.0/bt_cred_resolver.egg-info/PKG-INFO +11 -0
- bt_cred_resolver-0.1.0/bt_cred_resolver.egg-info/SOURCES.txt +12 -0
- bt_cred_resolver-0.1.0/bt_cred_resolver.egg-info/dependency_links.txt +1 -0
- bt_cred_resolver-0.1.0/bt_cred_resolver.egg-info/requires.txt +6 -0
- bt_cred_resolver-0.1.0/bt_cred_resolver.egg-info/top_level.txt +1 -0
- bt_cred_resolver-0.1.0/pyproject.toml +38 -0
- bt_cred_resolver-0.1.0/setup.cfg +4 -0
- bt_cred_resolver-0.1.0/setup.py +9 -0
- bt_cred_resolver-0.1.0/tests/test_credential_resolver.py +74 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bt-cred-resolver
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Utility classes for miscellaneous functions
|
|
5
|
+
Author-email: Bert Tejeda <berttejeda@gmail.com>
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Requires-Dist: keyring>=24.0.0
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: pytest>=7.4.0; extra == "dev"
|
|
10
|
+
Requires-Dist: pytest-cov>=4.1.0; extra == "dev"
|
|
11
|
+
Requires-Dist: pytest-mock>=3.11.1; extra == "dev"
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# bt-utils
|
|
2
|
+
|
|
3
|
+
Utility classes for credential resolution and OS password management.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install -e .
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
For development:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install -e ".[dev]"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from bt_cred_resolver import CredentialResolver, RequiredCredentialMissingError
|
|
21
|
+
|
|
22
|
+
# Initialize with optional logger
|
|
23
|
+
resolver = CredentialResolver(logger=logger)
|
|
24
|
+
|
|
25
|
+
# Resolve a credential from the OS password manager
|
|
26
|
+
token = resolver.resolve_credential(
|
|
27
|
+
'JIRA_PERSONAL_ACCESS_TOKEN',
|
|
28
|
+
keyring_name='JIRA_PERSONAL_ACCESS_TOKEN',
|
|
29
|
+
required=True
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# Or use the dict-based interface
|
|
33
|
+
jira_url = resolver.resolve(
|
|
34
|
+
'JIRA_URL',
|
|
35
|
+
sources={'keyring': 'JIRA_URL'},
|
|
36
|
+
required=True
|
|
37
|
+
)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
You can also use `PasswordManager` directly:
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from bt_cred_resolver import PasswordManager
|
|
44
|
+
|
|
45
|
+
pm = PasswordManager()
|
|
46
|
+
|
|
47
|
+
# Store a password
|
|
48
|
+
pm.store_password('MyService', 'username', 'secret_password')
|
|
49
|
+
|
|
50
|
+
# Retrieve a password
|
|
51
|
+
password = pm.retrieve_password('MyService', 'username')
|
|
52
|
+
|
|
53
|
+
# Delete a password
|
|
54
|
+
pm.delete_password('MyService', 'username')
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Running Tests
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pytest
|
|
61
|
+
```
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""
|
|
2
|
+
bt_cred_resolver provides utility classes for credential resolution and OS password management.
|
|
3
|
+
"""
|
|
4
|
+
from .credential_resolver import (
|
|
5
|
+
CredentialResolver,
|
|
6
|
+
CredentialResolverError,
|
|
7
|
+
RequiredCredentialMissingError,
|
|
8
|
+
)
|
|
9
|
+
from .password_manager import (
|
|
10
|
+
PasswordManager,
|
|
11
|
+
PasswordManagerError,
|
|
12
|
+
PasswordNotFoundError,
|
|
13
|
+
InvalidInputError,
|
|
14
|
+
StorageError,
|
|
15
|
+
RetrievalError,
|
|
16
|
+
DeletionError,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"CredentialResolver",
|
|
21
|
+
"CredentialResolverError",
|
|
22
|
+
"RequiredCredentialMissingError",
|
|
23
|
+
"PasswordManager",
|
|
24
|
+
"PasswordManagerError",
|
|
25
|
+
"PasswordNotFoundError",
|
|
26
|
+
"InvalidInputError",
|
|
27
|
+
"StorageError",
|
|
28
|
+
"RetrievalError",
|
|
29
|
+
"DeletionError",
|
|
30
|
+
"__version__",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
from importlib.metadata import version, PackageNotFoundError
|
|
35
|
+
except ImportError:
|
|
36
|
+
# Python < 3.8
|
|
37
|
+
try:
|
|
38
|
+
from importlib_metadata import version, PackageNotFoundError
|
|
39
|
+
except ImportError:
|
|
40
|
+
PackageNotFoundError = Exception
|
|
41
|
+
def version(package_name):
|
|
42
|
+
raise PackageNotFoundError
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
__version__ = version("bt-utils")
|
|
46
|
+
except (PackageNotFoundError, Exception):
|
|
47
|
+
# Package not installed, fall back to reading from pyproject.toml
|
|
48
|
+
import re
|
|
49
|
+
from pathlib import Path
|
|
50
|
+
|
|
51
|
+
pyproject_path = Path(__file__).parent.parent / "pyproject.toml"
|
|
52
|
+
if pyproject_path.exists():
|
|
53
|
+
content = pyproject_path.read_text(encoding="utf-8")
|
|
54
|
+
match = re.search(r'version\s*=\s*["\']([^"\']+)["\']', content)
|
|
55
|
+
if match:
|
|
56
|
+
__version__ = match.group(1)
|
|
57
|
+
else:
|
|
58
|
+
__version__ = "0.0.0"
|
|
59
|
+
else:
|
|
60
|
+
__version__ = "0.0.0"
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Credential Resolver Class
|
|
4
|
+
Provides a unified interface for resolving credentials from multiple sources:
|
|
5
|
+
1. OS Password Manager (lowest priority)
|
|
6
|
+
|
|
7
|
+
This follows the DRY principle by centralizing credential resolution logic
|
|
8
|
+
that was previously duplicated across multiple scripts.
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
from bt_cred_resolver import CredentialResolver
|
|
12
|
+
|
|
13
|
+
# Initialize with optional logger
|
|
14
|
+
resolver = CredentialResolver(logger=logger)
|
|
15
|
+
|
|
16
|
+
# Resolve a credential with automatic fallback
|
|
17
|
+
jira_url = resolver.resolve(
|
|
18
|
+
name='JIRA_URL',
|
|
19
|
+
sources={
|
|
20
|
+
'keyring': 'JIRA_URL'
|
|
21
|
+
},
|
|
22
|
+
required=True
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# Or use the simplified method for common patterns
|
|
26
|
+
token = resolver.resolve_credential(
|
|
27
|
+
'JIRA_PERSONAL_ACCESS_TOKEN',
|
|
28
|
+
keyring_name='JIRA_PERSONAL_ACCESS_TOKEN',
|
|
29
|
+
required=True
|
|
30
|
+
)
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
import os
|
|
34
|
+
from typing import Optional, Any, Dict
|
|
35
|
+
from .password_manager import (
|
|
36
|
+
PasswordManager,
|
|
37
|
+
PasswordManagerError,
|
|
38
|
+
PasswordNotFoundError,
|
|
39
|
+
RetrievalError
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class CredentialResolverError(Exception):
|
|
44
|
+
"""Base exception for credential resolver errors."""
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class RequiredCredentialMissingError(CredentialResolverError):
|
|
49
|
+
"""Raised when a required credential cannot be resolved."""
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class CredentialResolver:
|
|
54
|
+
"""
|
|
55
|
+
Resolves credentials from multiple sources with automatic fallback.
|
|
56
|
+
|
|
57
|
+
Attributes:
|
|
58
|
+
logger: Optional logger instance for logging resolution attempts.
|
|
59
|
+
password_manager: PasswordManager instance for keyring access.
|
|
60
|
+
password_manager_available: Boolean indicating if password manager is available.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, logger=None):
|
|
64
|
+
"""
|
|
65
|
+
Initialize the CredentialResolver.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
logger: Optional logger instance for logging. If None, logging is disabled.
|
|
69
|
+
"""
|
|
70
|
+
self.logger = logger
|
|
71
|
+
self.password_manager = None
|
|
72
|
+
self.password_manager_available = False
|
|
73
|
+
|
|
74
|
+
# Try to initialize password manager
|
|
75
|
+
try:
|
|
76
|
+
self.password_manager = PasswordManager()
|
|
77
|
+
self.password_manager_available = True
|
|
78
|
+
if self.logger:
|
|
79
|
+
self.logger.info('Password manager initialized successfully')
|
|
80
|
+
except Exception as e:
|
|
81
|
+
if self.logger:
|
|
82
|
+
self.logger.warning(f'Could not initialize password manager: {e}')
|
|
83
|
+
|
|
84
|
+
def _log_info(self, message: str):
|
|
85
|
+
"""Log info message if logger is available."""
|
|
86
|
+
if self.logger:
|
|
87
|
+
self.logger.info(message)
|
|
88
|
+
|
|
89
|
+
def _log_debug(self, message: str):
|
|
90
|
+
"""Log debug message if logger is available."""
|
|
91
|
+
if self.logger:
|
|
92
|
+
self.logger.debug(message)
|
|
93
|
+
|
|
94
|
+
def _log_warning(self, message: str):
|
|
95
|
+
"""Log warning message if logger is available."""
|
|
96
|
+
if self.logger:
|
|
97
|
+
self.logger.warning(message)
|
|
98
|
+
|
|
99
|
+
def _retrieve_from_keyring(self, keyring_name: str) -> Optional[str]:
|
|
100
|
+
"""
|
|
101
|
+
Retrieve a credential from the OS password manager.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
keyring_name: The service/username identifier in the keyring.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
The credential if found, None otherwise.
|
|
108
|
+
"""
|
|
109
|
+
if not self.password_manager_available:
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
credential = self.password_manager.retrieve_password(keyring_name, keyring_name)
|
|
114
|
+
self._log_info(f"Retrieved {keyring_name} from OS Password Manager")
|
|
115
|
+
return credential
|
|
116
|
+
except PasswordNotFoundError as e:
|
|
117
|
+
self._log_debug(f"{keyring_name} not found in password manager")
|
|
118
|
+
return None
|
|
119
|
+
except RetrievalError as e:
|
|
120
|
+
self._log_warning(f"Failed password manager retrieval for {keyring_name}: {e}")
|
|
121
|
+
return None
|
|
122
|
+
except Exception as e:
|
|
123
|
+
self._log_warning(f"Unexpected error retrieving {keyring_name} from password manager: {e}")
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
def resolve_credential(
|
|
127
|
+
self,
|
|
128
|
+
name: str,
|
|
129
|
+
keyring_name: Optional[str] = None,
|
|
130
|
+
required: bool = False,
|
|
131
|
+
default: Optional[Any] = None
|
|
132
|
+
) -> Optional[Any]:
|
|
133
|
+
"""
|
|
134
|
+
Resolve a credential from OS password manager with automatic fallback.
|
|
135
|
+
|
|
136
|
+
Resolution order:
|
|
137
|
+
1. OS Password Manager keyring (if keyring_name provided)
|
|
138
|
+
2. Default value (if provided)
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
name: Human-readable name of the credential (for error messages).
|
|
142
|
+
keyring_name: Name of keyring entry to check.
|
|
143
|
+
required: If True, raises an error if credential cannot be resolved.
|
|
144
|
+
default: Default value if credential cannot be resolved.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
The resolved credential value, or None if not found and not required.
|
|
148
|
+
|
|
149
|
+
Raises:
|
|
150
|
+
RequiredCredentialMissingError: If required=True and credential cannot be resolved.
|
|
151
|
+
|
|
152
|
+
Example:
|
|
153
|
+
>>> resolver = CredentialResolver(logger=logger)
|
|
154
|
+
>>> jira_url = resolver.resolve_credential(
|
|
155
|
+
... 'JIRA_URL',
|
|
156
|
+
... keyring_name='JIRA_URL',
|
|
157
|
+
... required=True
|
|
158
|
+
... )
|
|
159
|
+
"""
|
|
160
|
+
# Try each source in priority order
|
|
161
|
+
sources_tried = []
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# 4. Password manager keyring
|
|
165
|
+
if keyring_name:
|
|
166
|
+
keyring_value = self._retrieve_from_keyring(keyring_name)
|
|
167
|
+
if keyring_value is not None:
|
|
168
|
+
return keyring_value
|
|
169
|
+
sources_tried.append(f'password manager ({keyring_name})')
|
|
170
|
+
|
|
171
|
+
# 5. Default value
|
|
172
|
+
if default is not None:
|
|
173
|
+
self._log_debug(f"Using default value for {name}")
|
|
174
|
+
return default
|
|
175
|
+
|
|
176
|
+
# If we get here, credential was not found
|
|
177
|
+
if required:
|
|
178
|
+
sources_str = ', '.join(sources_tried)
|
|
179
|
+
error_msg = (
|
|
180
|
+
f"Could not determine {name} from {sources_str}. "
|
|
181
|
+
f"Please provide it via CLI argument, configuration file, "
|
|
182
|
+
f"environment variable, or password manager."
|
|
183
|
+
)
|
|
184
|
+
self._log_warning(error_msg)
|
|
185
|
+
raise RequiredCredentialMissingError(error_msg)
|
|
186
|
+
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
def resolve(
|
|
190
|
+
self,
|
|
191
|
+
name: str,
|
|
192
|
+
sources: Dict[str, Any],
|
|
193
|
+
required: bool = False,
|
|
194
|
+
default: Optional[Any] = None
|
|
195
|
+
) -> Optional[Any]:
|
|
196
|
+
"""
|
|
197
|
+
Resolve a credential using a dictionary of sources.
|
|
198
|
+
|
|
199
|
+
This is an alternative interface that uses a sources dictionary
|
|
200
|
+
instead of individual keyword arguments.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
name: Human-readable name of the credential.
|
|
204
|
+
sources: Dictionary with keys: 'keyring'.
|
|
205
|
+
required: If True, raises an error if credential cannot be resolved.
|
|
206
|
+
default: Default value if credential cannot be resolved.
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
The resolved credential value.
|
|
210
|
+
|
|
211
|
+
Example:
|
|
212
|
+
>>> resolver.resolve(
|
|
213
|
+
... 'JIRA_URL',
|
|
214
|
+
... sources={
|
|
215
|
+
... 'keyring': 'JIRA_URL'
|
|
216
|
+
... },
|
|
217
|
+
... required=True
|
|
218
|
+
... )
|
|
219
|
+
"""
|
|
220
|
+
return self.resolve_credential(
|
|
221
|
+
name=name,
|
|
222
|
+
keyring_name=sources.get('keyring'),
|
|
223
|
+
required=required,
|
|
224
|
+
default=default
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
def batch_resolve(
|
|
228
|
+
self,
|
|
229
|
+
credentials: Dict[str, Dict[str, Any]]
|
|
230
|
+
) -> Dict[str, Any]:
|
|
231
|
+
"""
|
|
232
|
+
Resolve multiple credentials at once.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
credentials: Dictionary where keys are credential names and values
|
|
236
|
+
are dictionaries of resolution parameters.
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
Dictionary of resolved credentials.
|
|
240
|
+
|
|
241
|
+
Example:
|
|
242
|
+
>>> resolved = resolver.batch_resolve({
|
|
243
|
+
... 'jira_url': {
|
|
244
|
+
... 'keyring_name': 'JIRA_URL',
|
|
245
|
+
... 'required': True
|
|
246
|
+
... },
|
|
247
|
+
... 'jira_token': {
|
|
248
|
+
... 'keyring_name': 'JIRA_PERSONAL_ACCESS_TOKEN',
|
|
249
|
+
... 'required': True
|
|
250
|
+
... }
|
|
251
|
+
... })
|
|
252
|
+
>>> print(resolved['jira_url'])
|
|
253
|
+
"""
|
|
254
|
+
results = {}
|
|
255
|
+
for cred_name, params in credentials.items():
|
|
256
|
+
results[cred_name] = self.resolve_credential(
|
|
257
|
+
name=cred_name,
|
|
258
|
+
**params
|
|
259
|
+
)
|
|
260
|
+
return results
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Password Manager Class
|
|
4
|
+
Secure password storage and retrieval using the OS's native password manager.
|
|
5
|
+
Supports macOS (Darwin), Linux, and Windows platforms.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
from bt_cred_resolver import PasswordManager
|
|
9
|
+
|
|
10
|
+
pm = PasswordManager()
|
|
11
|
+
|
|
12
|
+
# Store a password
|
|
13
|
+
pm.store_password('MyService', 'username', 'secret_password')
|
|
14
|
+
|
|
15
|
+
# Retrieve a password
|
|
16
|
+
password = pm.retrieve_password('MyService', 'username')
|
|
17
|
+
|
|
18
|
+
# Delete a password
|
|
19
|
+
pm.delete_password('MyService', 'username')
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import platform
|
|
23
|
+
import keyring
|
|
24
|
+
from typing import Optional, Dict
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PasswordManagerError(Exception):
|
|
28
|
+
"""Base exception for password manager errors."""
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class InvalidInputError(PasswordManagerError):
|
|
33
|
+
"""Raised when invalid input is provided."""
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PasswordNotFoundError(PasswordManagerError):
|
|
38
|
+
"""Raised when a password is not found in the keyring."""
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class StorageError(PasswordManagerError):
|
|
43
|
+
"""Raised when there's an error storing a password."""
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class RetrievalError(PasswordManagerError):
|
|
48
|
+
"""Raised when there's an error retrieving a password."""
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class DeletionError(PasswordManagerError):
|
|
53
|
+
"""Raised when there's an error deleting a password."""
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class PasswordManager:
|
|
58
|
+
"""
|
|
59
|
+
Manages passwords using the operating system's native password manager.
|
|
60
|
+
|
|
61
|
+
Attributes:
|
|
62
|
+
platform_name (str): The detected operating system name.
|
|
63
|
+
backend_name (str): The keyring backend being used.
|
|
64
|
+
manager_name (str): The name of the password manager in use.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self):
|
|
68
|
+
"""Initialize the PasswordManager with platform detection."""
|
|
69
|
+
self.platform_name = self._detect_platform()
|
|
70
|
+
self.backend_name = self._get_keyring_backend()
|
|
71
|
+
self.manager_name = self._get_manager_name()
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def _detect_platform() -> str:
|
|
75
|
+
"""
|
|
76
|
+
Detect the operating system.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
str: The platform name (macOS, Linux, or Windows).
|
|
80
|
+
"""
|
|
81
|
+
system = platform.system()
|
|
82
|
+
platform_map = {
|
|
83
|
+
'Darwin': 'macOS',
|
|
84
|
+
'Linux': 'Linux',
|
|
85
|
+
'Windows': 'Windows'
|
|
86
|
+
}
|
|
87
|
+
return platform_map.get(system, system)
|
|
88
|
+
|
|
89
|
+
@staticmethod
|
|
90
|
+
def _get_keyring_backend() -> str:
|
|
91
|
+
"""
|
|
92
|
+
Get the current keyring backend name.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
str: The keyring backend class name.
|
|
96
|
+
"""
|
|
97
|
+
try:
|
|
98
|
+
backend = keyring.get_keyring()
|
|
99
|
+
return backend.__class__.__name__
|
|
100
|
+
except Exception as e:
|
|
101
|
+
return f"Unknown ({str(e)})"
|
|
102
|
+
|
|
103
|
+
def _get_manager_name(self) -> str:
|
|
104
|
+
"""
|
|
105
|
+
Get the name of the password manager based on the platform.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
str: The password manager name.
|
|
109
|
+
"""
|
|
110
|
+
system = platform.system()
|
|
111
|
+
manager_map = {
|
|
112
|
+
'Darwin': 'macOS Keychain',
|
|
113
|
+
'Windows': 'Windows Credential Manager',
|
|
114
|
+
'Linux': 'Secret Service (GNOME Keyring/KWallet)'
|
|
115
|
+
}
|
|
116
|
+
return manager_map.get(system, 'System Keyring')
|
|
117
|
+
|
|
118
|
+
def _validate_input(self, service: str, username: str,
|
|
119
|
+
password: Optional[str] = None) -> None:
|
|
120
|
+
"""
|
|
121
|
+
Validate input parameters.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
service: The service name.
|
|
125
|
+
username: The username.
|
|
126
|
+
password: The password (optional, only for storage).
|
|
127
|
+
|
|
128
|
+
Raises:
|
|
129
|
+
InvalidInputError: If any required parameter is invalid.
|
|
130
|
+
"""
|
|
131
|
+
if not service or not isinstance(service, str) or not service.strip():
|
|
132
|
+
raise InvalidInputError("Service name must be a non-empty string")
|
|
133
|
+
|
|
134
|
+
if not username or not isinstance(username, str) or not username.strip():
|
|
135
|
+
raise InvalidInputError("Username must be a non-empty string")
|
|
136
|
+
|
|
137
|
+
if password is not None:
|
|
138
|
+
if not isinstance(password, str) or not password:
|
|
139
|
+
raise InvalidInputError("Password must be a non-empty string")
|
|
140
|
+
|
|
141
|
+
def store_password(self, service: str, username: str, password: str) -> Dict[str, str]:
|
|
142
|
+
"""
|
|
143
|
+
Store a password in the OS password manager.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
service: The service name (e.g., 'MySQLDatabase', 'GitHubAPI').
|
|
147
|
+
username: The username/account name.
|
|
148
|
+
password: The password to store.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
dict: A dictionary with storage confirmation details.
|
|
152
|
+
|
|
153
|
+
Raises:
|
|
154
|
+
InvalidInputError: If any parameter is invalid.
|
|
155
|
+
StorageError: If there's an error storing the password.
|
|
156
|
+
|
|
157
|
+
Example:
|
|
158
|
+
>>> pm = PasswordManager()
|
|
159
|
+
>>> result = pm.store_password('MyApp', 'user123', 'secret_pass')
|
|
160
|
+
>>> print(result['status'])
|
|
161
|
+
'success'
|
|
162
|
+
"""
|
|
163
|
+
# Validate input
|
|
164
|
+
self._validate_input(service, username, password)
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
# Store the password
|
|
168
|
+
keyring.set_password(service, username, password)
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
'status': 'success',
|
|
172
|
+
'service': service,
|
|
173
|
+
'username': username,
|
|
174
|
+
'platform': self.platform_name,
|
|
175
|
+
'manager': self.manager_name,
|
|
176
|
+
'message': f'Password stored successfully in {self.manager_name}'
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
except Exception as e:
|
|
180
|
+
raise StorageError(
|
|
181
|
+
f"Failed to store password for {username}@{service}: {str(e)}"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
def retrieve_password(self, service: str, username: str) -> str:
|
|
185
|
+
"""
|
|
186
|
+
Retrieve a password from the OS password manager.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
service: The service name.
|
|
190
|
+
username: The username/account name.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
str: The retrieved password.
|
|
194
|
+
|
|
195
|
+
Raises:
|
|
196
|
+
InvalidInputError: If any parameter is invalid.
|
|
197
|
+
PasswordNotFoundError: If the password is not found.
|
|
198
|
+
RetrievalError: If there's an error retrieving the password.
|
|
199
|
+
|
|
200
|
+
Example:
|
|
201
|
+
>>> pm = PasswordManager()
|
|
202
|
+
>>> password = pm.retrieve_password('MyApp', 'user123')
|
|
203
|
+
>>> print(password)
|
|
204
|
+
'secret_pass'
|
|
205
|
+
"""
|
|
206
|
+
# Validate input
|
|
207
|
+
self._validate_input(service, username)
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
# Retrieve the password
|
|
211
|
+
password = keyring.get_password(service, username)
|
|
212
|
+
|
|
213
|
+
if password is None:
|
|
214
|
+
raise PasswordNotFoundError(
|
|
215
|
+
f"No password found for {username}@{service}. "
|
|
216
|
+
f"Use store_password() to store it first."
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
return password
|
|
220
|
+
|
|
221
|
+
except PasswordNotFoundError:
|
|
222
|
+
# Re-raise our custom exception
|
|
223
|
+
raise
|
|
224
|
+
except Exception as e:
|
|
225
|
+
raise RetrievalError(
|
|
226
|
+
f"Failed to retrieve password for {username}@{service}: {str(e)}"
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
def delete_password(self, service: str, username: str) -> Dict[str, str]:
|
|
230
|
+
"""
|
|
231
|
+
Delete a password from the OS password manager.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
service: The service name.
|
|
235
|
+
username: The username/account name.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
dict: A dictionary with deletion confirmation details.
|
|
239
|
+
|
|
240
|
+
Raises:
|
|
241
|
+
InvalidInputError: If any parameter is invalid.
|
|
242
|
+
PasswordNotFoundError: If the password is not found.
|
|
243
|
+
DeletionError: If there's an error deleting the password.
|
|
244
|
+
|
|
245
|
+
Example:
|
|
246
|
+
>>> pm = PasswordManager()
|
|
247
|
+
>>> result = pm.delete_password('MyApp', 'user123')
|
|
248
|
+
>>> print(result['status'])
|
|
249
|
+
'success'
|
|
250
|
+
"""
|
|
251
|
+
# Validate input
|
|
252
|
+
self._validate_input(service, username)
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
# Check if password exists first
|
|
256
|
+
password = keyring.get_password(service, username)
|
|
257
|
+
if password is None:
|
|
258
|
+
raise PasswordNotFoundError(
|
|
259
|
+
f"No password found for {username}@{service}"
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
# Delete the password
|
|
263
|
+
keyring.delete_password(service, username)
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
'status': 'success',
|
|
267
|
+
'service': service,
|
|
268
|
+
'username': username,
|
|
269
|
+
'platform': self.platform_name,
|
|
270
|
+
'manager': self.manager_name,
|
|
271
|
+
'message': f'Password deleted successfully from {self.manager_name}'
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
except PasswordNotFoundError:
|
|
275
|
+
# Re-raise our custom exception
|
|
276
|
+
raise
|
|
277
|
+
except Exception as e:
|
|
278
|
+
raise DeletionError(
|
|
279
|
+
f"Failed to delete password for {username}@{service}: {str(e)}"
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
def password_exists(self, service: str, username: str) -> bool:
|
|
283
|
+
"""
|
|
284
|
+
Check if a password exists in the OS password manager.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
service: The service name.
|
|
288
|
+
username: The username/account name.
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
bool: True if the password exists, False otherwise.
|
|
292
|
+
|
|
293
|
+
Raises:
|
|
294
|
+
InvalidInputError: If any parameter is invalid.
|
|
295
|
+
|
|
296
|
+
Example:
|
|
297
|
+
>>> pm = PasswordManager()
|
|
298
|
+
>>> if pm.password_exists('MyApp', 'user123'):
|
|
299
|
+
... print("Password exists")
|
|
300
|
+
"""
|
|
301
|
+
# Validate input
|
|
302
|
+
self._validate_input(service, username)
|
|
303
|
+
|
|
304
|
+
try:
|
|
305
|
+
password = keyring.get_password(service, username)
|
|
306
|
+
return password is not None
|
|
307
|
+
except Exception:
|
|
308
|
+
return False
|
|
309
|
+
|
|
310
|
+
def get_info(self) -> Dict[str, str]:
|
|
311
|
+
"""
|
|
312
|
+
Get information about the password manager configuration.
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
dict: A dictionary with platform and backend information.
|
|
316
|
+
|
|
317
|
+
Example:
|
|
318
|
+
>>> pm = PasswordManager()
|
|
319
|
+
>>> info = pm.get_info()
|
|
320
|
+
>>> print(info['platform'])
|
|
321
|
+
'macOS'
|
|
322
|
+
"""
|
|
323
|
+
return {
|
|
324
|
+
'platform': self.platform_name,
|
|
325
|
+
'backend': self.backend_name,
|
|
326
|
+
'manager': self.manager_name
|
|
327
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bt-cred-resolver
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Utility classes for miscellaneous functions
|
|
5
|
+
Author-email: Bert Tejeda <berttejeda@gmail.com>
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Requires-Dist: keyring>=24.0.0
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: pytest>=7.4.0; extra == "dev"
|
|
10
|
+
Requires-Dist: pytest-cov>=4.1.0; extra == "dev"
|
|
11
|
+
Requires-Dist: pytest-mock>=3.11.1; extra == "dev"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
setup.py
|
|
4
|
+
bt_cred_resolver/__init__.py
|
|
5
|
+
bt_cred_resolver/credential_resolver.py
|
|
6
|
+
bt_cred_resolver/password_manager.py
|
|
7
|
+
bt_cred_resolver.egg-info/PKG-INFO
|
|
8
|
+
bt_cred_resolver.egg-info/SOURCES.txt
|
|
9
|
+
bt_cred_resolver.egg-info/dependency_links.txt
|
|
10
|
+
bt_cred_resolver.egg-info/requires.txt
|
|
11
|
+
bt_cred_resolver.egg-info/top_level.txt
|
|
12
|
+
tests/test_credential_resolver.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
bt_cred_resolver
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "bt-cred-resolver"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Utility classes for miscellaneous functions"
|
|
9
|
+
requires-python = ">=3.9"
|
|
10
|
+
authors = [{ name = "Bert Tejeda", email = "berttejeda@gmail.com" }]
|
|
11
|
+
dependencies = [
|
|
12
|
+
"keyring>=24.0.0",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
dev = [
|
|
17
|
+
"pytest>=7.4.0",
|
|
18
|
+
"pytest-cov>=4.1.0",
|
|
19
|
+
"pytest-mock>=3.11.1",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[tool.setuptools]
|
|
23
|
+
packages = ["bt_cred_resolver"]
|
|
24
|
+
|
|
25
|
+
[tool.pytest.ini_options]
|
|
26
|
+
testpaths = ["tests"]
|
|
27
|
+
python_files = ["test_*.py"]
|
|
28
|
+
python_classes = ["Test*"]
|
|
29
|
+
python_functions = ["test_*"]
|
|
30
|
+
addopts = [
|
|
31
|
+
"-v",
|
|
32
|
+
"--strict-markers",
|
|
33
|
+
"--tb=short",
|
|
34
|
+
]
|
|
35
|
+
markers = [
|
|
36
|
+
"unit: Unit tests",
|
|
37
|
+
"integration: Integration tests",
|
|
38
|
+
]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Setup script for bt-utils package.
|
|
2
|
+
|
|
3
|
+
This is a minimal setup.py that allows using traditional build commands.
|
|
4
|
+
The actual package configuration is in pyproject.toml.
|
|
5
|
+
"""
|
|
6
|
+
from setuptools import setup
|
|
7
|
+
|
|
8
|
+
# setuptools will automatically read from pyproject.toml
|
|
9
|
+
setup()
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Tests for the CredentialResolver class."""
|
|
2
|
+
import pytest
|
|
3
|
+
from unittest.mock import MagicMock, patch
|
|
4
|
+
|
|
5
|
+
from bt_cred_resolver import (
|
|
6
|
+
CredentialResolver,
|
|
7
|
+
RequiredCredentialMissingError,
|
|
8
|
+
PasswordManager,
|
|
9
|
+
PasswordNotFoundError,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture
|
|
14
|
+
def mock_logger():
|
|
15
|
+
"""Create a mock logger."""
|
|
16
|
+
logger = MagicMock()
|
|
17
|
+
return logger
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture
|
|
21
|
+
def resolver(mock_logger):
|
|
22
|
+
"""Create a CredentialResolver with mocked password manager."""
|
|
23
|
+
with patch.object(PasswordManager, '__init__', return_value=None):
|
|
24
|
+
r = CredentialResolver(logger=mock_logger)
|
|
25
|
+
r.password_manager = MagicMock(spec=PasswordManager)
|
|
26
|
+
r.password_manager_available = True
|
|
27
|
+
return r
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TestCredentialResolver:
|
|
31
|
+
"""Tests for CredentialResolver."""
|
|
32
|
+
|
|
33
|
+
def test_resolve_from_keyring(self, resolver):
|
|
34
|
+
resolver.password_manager.retrieve_password.return_value = "secret_value"
|
|
35
|
+
result = resolver.resolve_credential("MY_CRED", keyring_name="MY_CRED")
|
|
36
|
+
assert result == "secret_value"
|
|
37
|
+
|
|
38
|
+
def test_resolve_fallback_to_default(self, resolver):
|
|
39
|
+
resolver.password_manager.retrieve_password.side_effect = PasswordNotFoundError("nope")
|
|
40
|
+
result = resolver.resolve_credential(
|
|
41
|
+
"MY_CRED", keyring_name="MY_CRED", default="fallback"
|
|
42
|
+
)
|
|
43
|
+
assert result == "fallback"
|
|
44
|
+
|
|
45
|
+
def test_resolve_required_raises(self, resolver):
|
|
46
|
+
resolver.password_manager.retrieve_password.side_effect = PasswordNotFoundError("nope")
|
|
47
|
+
with pytest.raises(RequiredCredentialMissingError):
|
|
48
|
+
resolver.resolve_credential("MY_CRED", keyring_name="MY_CRED", required=True)
|
|
49
|
+
|
|
50
|
+
def test_resolve_returns_none_when_not_required(self, resolver):
|
|
51
|
+
resolver.password_manager.retrieve_password.side_effect = PasswordNotFoundError("nope")
|
|
52
|
+
result = resolver.resolve_credential("MY_CRED", keyring_name="MY_CRED")
|
|
53
|
+
assert result is None
|
|
54
|
+
|
|
55
|
+
def test_resolve_dict_interface(self, resolver):
|
|
56
|
+
resolver.password_manager.retrieve_password.return_value = "val"
|
|
57
|
+
result = resolver.resolve("MY_CRED", sources={"keyring": "MY_CRED"})
|
|
58
|
+
assert result == "val"
|
|
59
|
+
|
|
60
|
+
def test_batch_resolve(self, resolver):
|
|
61
|
+
resolver.password_manager.retrieve_password.return_value = "val"
|
|
62
|
+
results = resolver.batch_resolve({
|
|
63
|
+
"cred_a": {"keyring_name": "CRED_A"},
|
|
64
|
+
"cred_b": {"keyring_name": "CRED_B"},
|
|
65
|
+
})
|
|
66
|
+
assert results["cred_a"] == "val"
|
|
67
|
+
assert results["cred_b"] == "val"
|
|
68
|
+
|
|
69
|
+
def test_password_manager_unavailable(self, mock_logger):
|
|
70
|
+
with patch.object(PasswordManager, '__init__', side_effect=Exception("no backend")):
|
|
71
|
+
r = CredentialResolver(logger=mock_logger)
|
|
72
|
+
assert r.password_manager_available is False
|
|
73
|
+
result = r.resolve_credential("MY_CRED", keyring_name="MY_CRED")
|
|
74
|
+
assert result is None
|