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
38from __future__ import annotations
49
1722
1823logger = 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 :
0 commit comments