Skip to content

Commit ae83da5

Browse files
committed
feat: store connection passwords in OS keychain via keyring
- Add keyring>=25.0 to runtime dependencies - ConnectionManager stores passwords in OS keychain (macOS Keychain, GNOME Keyring, Windows Credential Manager) instead of plaintext YAML - save_connection: strips password from connections.yaml, sets _keyring flag, falls back to plaintext with warning if keychain unavailable - get_connection: fetches password from keychain when _keyring flag set - remove_connection: also deletes the keychain entry on removal - Add mock_keyring autouse fixture in conftest.py to redirect keyring calls to an in-memory dict for all unit tests
1 parent e111ecf commit ae83da5

4 files changed

Lines changed: 293 additions & 10 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ dependencies = [
99
"pyyaml>=6.0",
1010
"psycopg[binary]>=3.1",
1111
"pymysql>=1.1",
12+
"keyring>=25.0",
1213
]
1314

1415
[project.scripts]

src/open_data_agent/db/connection.py

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
"""ConnectionManager — named DB connections stored in ~/.config/open-data-agent/connections.yaml."""
1+
"""ConnectionManager — named DB connections stored in ~/.config/open-data-agent/connections.yaml.
2+
3+
Passwords are stored in the OS keychain via the `keyring` library (macOS Keychain, GNOME Keyring,
4+
Windows Credential Manager). On headless environments where no keyring backend is available,
5+
passwords fall back to plaintext in connections.yaml with a warning.
6+
"""
27

38
from __future__ import annotations
49

@@ -17,6 +22,8 @@
1722

1823
logger = logging.getLogger("open_data_agent.db.connection")
1924

25+
_KEYRING_SERVICE = "open-data-agent"
26+
2027
_CONNECTIONS_FILE = "connections.yaml"
2128
_ACTIVE_CONNECTION_FILE = "active-connection"
2229

@@ -60,6 +67,37 @@ def _save_connections(self, connections: dict[str, dict[str, Any]]) -> None:
6067
with os.fdopen(fd, "w") as f:
6168
yaml.dump(connections, f, default_flow_style=False)
6269

70+
def _keyring_set(self, name: str, password: str) -> bool:
71+
"""Store password in OS keychain. Returns True on success, False if unavailable."""
72+
try:
73+
import keyring
74+
import keyring.errors
75+
76+
keyring.set_password(_KEYRING_SERVICE, name, password)
77+
return True
78+
except Exception as exc: # noqa: BLE001
79+
logger.warning("keyring unavailable — storing password in plaintext: %s", exc)
80+
return False
81+
82+
def _keyring_get(self, name: str) -> str | None:
83+
"""Retrieve password from OS keychain, or None if unavailable/not found."""
84+
try:
85+
import keyring
86+
87+
return keyring.get_password(_KEYRING_SERVICE, name)
88+
except Exception: # noqa: BLE001
89+
return None
90+
91+
def _keyring_delete(self, name: str) -> None:
92+
"""Delete keychain entry; silently no-ops if not found or unavailable."""
93+
try:
94+
import keyring
95+
import keyring.errors
96+
97+
keyring.delete_password(_KEYRING_SERVICE, name)
98+
except Exception: # noqa: BLE001
99+
pass
100+
63101
def save_connection(self, name: str, params: dict[str, Any]) -> None:
64102
"""Save a named connection. Raises ConfigError on missing/invalid fields."""
65103
missing = _REQUIRED_FIELDS - set(params.keys())
@@ -72,8 +110,17 @@ def save_connection(self, name: str, params: dict[str, Any]) -> None:
72110
f"Must be one of: {', '.join(sorted(_VALID_DB_TYPES))}"
73111
)
74112

113+
stored = dict(params)
114+
password = str(stored.pop("password", ""))
115+
116+
# Try keyring first; fall back to plaintext if unavailable.
117+
if self._keyring_set(name, password):
118+
stored["_keyring"] = True
119+
else:
120+
stored["password"] = password
121+
75122
connections = self._load_connections()
76-
connections[name] = dict(params)
123+
connections[name] = stored
77124
self._save_connections(connections)
78125
logger.info("Saved connection '%s' (db_type=%s)", name, params["db_type"])
79126

@@ -88,19 +135,29 @@ def list_connections(self) -> list[dict[str, Any]]:
88135
return result
89136

90137
def get_connection(self, name: str) -> dict[str, Any]:
91-
"""Return the full connection dict (including password). Raises ConfigError if not found."""
138+
"""Return full connection dict including password. Raises ConfigError if not found."""
92139
connections = self._load_connections()
93140
if name not in connections:
94141
raise ConfigError(f"Connection '{name}' not found in connections.yaml")
95-
return dict(connections[name])
142+
entry = dict(connections[name])
143+
if entry.pop("_keyring", False):
144+
password = self._keyring_get(name)
145+
if password is None:
146+
raise ConfigError(
147+
f"Password for '{name}' not found in keychain. "
148+
"Re-add the connection with 'oda connections add'."
149+
)
150+
entry["password"] = password
151+
return entry
96152

97153
def remove_connection(self, name: str) -> None:
98-
"""Remove a named connection. Raises ConfigError if not found."""
154+
"""Remove a named connection and its keychain entry. Raises ConfigError if not found."""
99155
connections = self._load_connections()
100156
if name not in connections:
101157
raise ConfigError(f"Connection '{name}' not found")
102158
del connections[name]
103159
self._save_connections(connections)
160+
self._keyring_delete(name)
104161
logger.info("Removed connection '%s'", name)
105162

106163
def set_active_connection(self, name: str) -> None:

tests/conftest.py

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,62 @@
1-
"""Shared pytest fixtures for all tests.
2-
3-
Key fixtures:
4-
sqlite_db: session-scoped in-memory SQLite connection with standard fixture schema.
5-
"""
1+
"""Shared pytest fixtures for all tests."""
62

73
from __future__ import annotations
84

95
import sqlite3
106
from collections.abc import Generator
117
from pathlib import Path
8+
from unittest.mock import patch
129

1310
import pytest
1411

1512
_FIXTURES_DIR = Path(__file__).parent / "fixtures"
1613

14+
# ---------------------------------------------------------------------------
15+
# Keyring: replace OS keychain with an in-memory dict for all unit tests.
16+
# Without this, tests would either require a real keychain or fail on CI.
17+
# ---------------------------------------------------------------------------
18+
19+
20+
@pytest.fixture(autouse=True)
21+
def mock_keyring() -> Generator[dict[str, str], None, None]:
22+
"""Autouse fixture: redirect keyring to an in-memory store for all tests."""
23+
store: dict[str, str] = {}
24+
25+
def _set(name: str, password: str) -> bool:
26+
store[name] = password
27+
return True
28+
29+
def _get(name: str) -> str | None:
30+
return store.get(name)
31+
32+
def _delete(name: str) -> None:
33+
store.pop(name, None)
34+
35+
with (
36+
patch.object(
37+
__import__(
38+
"open_data_agent.db.connection", fromlist=["ConnectionManager"]
39+
).ConnectionManager,
40+
"_keyring_set",
41+
lambda self, n, p: _set(n, p),
42+
),
43+
patch.object(
44+
__import__(
45+
"open_data_agent.db.connection", fromlist=["ConnectionManager"]
46+
).ConnectionManager,
47+
"_keyring_get",
48+
lambda self, n: _get(n),
49+
),
50+
patch.object(
51+
__import__(
52+
"open_data_agent.db.connection", fromlist=["ConnectionManager"]
53+
).ConnectionManager,
54+
"_keyring_delete",
55+
lambda self, n: _delete(n),
56+
),
57+
):
58+
yield store
59+
1760

1861
@pytest.fixture(scope="session")
1962
def sqlite_db() -> Generator[sqlite3.Connection, None, None]:

0 commit comments

Comments
 (0)