Coverage for python/lsst/daf/butler/registry/_dbAuth.py: 12%
82 statements
« prev ^ index » next coverage.py v7.2.5, created at 2023-05-06 09:33 +0000
« prev ^ index » next coverage.py v7.2.5, created at 2023-05-06 09:33 +0000
1# This file is part of daf_butler
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (http://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <http://www.gnu.org/licenses/>.
22from __future__ import annotations
24import fnmatch
25import os
26import stat
27import urllib.parse
28from typing import Dict, List, Optional, Tuple, Union
30import yaml
32__all__ = ["DbAuth", "DbAuthError", "DbAuthPermissionsError"]
35class DbAuthError(RuntimeError):
36 """Exception raised when a problem has occurred retrieving database
37 authentication information.
38 """
40 pass
43class DbAuthNotFoundError(DbAuthError):
44 """Credentials file does not exist or no match was found in it."""
47class DbAuthPermissionsError(DbAuthError):
48 """Credentials file has incorrect permissions."""
51class DbAuth:
52 """Retrieves authentication information for database connections.
54 The authorization configuration is taken from the ``authList`` parameter
55 or a (group- and world-inaccessible) YAML file located at a path specified
56 by the given environment variable or at a default path location.
58 Parameters
59 ----------
60 path : `str` or None, optional
61 Path to configuration file.
62 envVar : `str` or None, optional
63 Name of environment variable pointing to configuration file.
64 authList : `list` [`dict`] or None, optional
65 Authentication configuration.
67 Notes
68 -----
69 At least one of ``path``, ``envVar``, or ``authList`` must be provided;
70 generally ``path`` should be provided as a default location.
71 """
73 def __init__(
74 self,
75 path: Optional[str] = None,
76 envVar: Optional[str] = None,
77 authList: Optional[List[Dict[str, str]]] = None,
78 ):
79 if authList is not None:
80 self.authList = authList
81 return
82 if envVar is not None and envVar in os.environ:
83 secretPath = os.path.expanduser(os.environ[envVar])
84 elif path is None:
85 raise DbAuthNotFoundError("No default path provided to DbAuth configuration file")
86 else:
87 secretPath = os.path.expanduser(path)
88 if not os.path.isfile(secretPath):
89 raise DbAuthNotFoundError(f"No DbAuth configuration file: {secretPath}")
90 mode = os.stat(secretPath).st_mode
91 if mode & (stat.S_IRWXG | stat.S_IRWXO) != 0:
92 raise DbAuthPermissionsError(
93 "DbAuth configuration file {} has incorrect permissions: {:o}".format(secretPath, mode)
94 )
96 try:
97 with open(secretPath) as secretFile:
98 self.authList = yaml.safe_load(secretFile)
99 except Exception as exc:
100 raise DbAuthError(f"Unable to load DbAuth configuration file: {secretPath}.") from exc
102 # dialectname, hose, and database are tagged as Optional only because other
103 # routines delegate to this one in order to raise a consistent exception
104 # for that condition.
105 def getAuth(
106 self,
107 dialectname: Optional[str],
108 username: Optional[str],
109 host: Optional[str],
110 port: Optional[Union[int, str]],
111 database: Optional[str],
112 ) -> Tuple[Optional[str], str]:
113 """Retrieve a username and password for a database connection.
115 This function matches elements from the database connection URL with
116 glob-like URL patterns in a list of configuration dictionaries.
118 Parameters
119 ----------
120 dialectname : `str`
121 Database dialect, for example sqlite, mysql, postgresql, oracle,
122 or mssql.
123 username : `str` or None
124 Username from connection URL if present.
125 host : `str`
126 Host name from connection URL if present.
127 port : `str` or `int` or None
128 Port from connection URL if present.
129 database : `str`
130 Database name from connection URL.
132 Returns
133 -------
134 username: `str`
135 Username to use for database connection; same as parameter if
136 present.
137 password: `str`
138 Password to use for database connection.
140 Raises
141 ------
142 DbAuthError
143 Raised if the input is missing elements, an authorization
144 dictionary is missing elements, the authorization file is
145 misconfigured, or no matching authorization is found.
147 Notes
148 -----
149 The list of authorization configuration dictionaries is tested in
150 order, with the first matching dictionary used. Each dictionary must
151 contain a ``url`` item with a pattern to match against the database
152 connection URL and a ``password`` item. If no username is provided in
153 the database connection URL, the dictionary must also contain a
154 ``username`` item.
156 The ``url`` item must begin with a dialect and is not allowed to
157 specify dialect+driver.
159 Glob-style patterns (using "``*``" and "``?``" as wildcards) can be
160 used to match the host and database name portions of the connection
161 URL. For the username, port, and database name portions, omitting them
162 from the pattern matches against any value in the connection URL.
164 Examples
165 --------
167 The connection URL
168 ``postgresql://user@host.example.com:5432/my_database`` matches against
169 the identical string as a pattern. Other patterns that would match
170 include:
172 * ``postgresql://*``
173 * ``postgresql://*.example.com``
174 * ``postgresql://*.example.com/my_*``
175 * ``postgresql://host.example.com/my_database``
176 * ``postgresql://host.example.com:5432/my_database``
177 * ``postgresql://user@host.example.com/my_database``
179 Note that the connection URL
180 ``postgresql://host.example.com/my_database`` would not match against
181 the pattern ``postgresql://host.example.com:5432``, even if the default
182 port for the connection is 5432.
183 """
184 # Check inputs, squashing MyPy warnings that they're unnecessary
185 # (since they're only unnecessary if everyone else runs MyPy).
186 if dialectname is None or dialectname == "":
187 raise DbAuthError("Missing dialectname parameter")
188 if host is None or host == "":
189 raise DbAuthError("Missing host parameter")
190 if database is None or database == "":
191 raise DbAuthError("Missing database parameter")
193 for authDict in self.authList:
194 # Check for mandatory entries
195 if "url" not in authDict:
196 raise DbAuthError("Missing URL in DbAuth configuration")
198 # Parse pseudo-URL from db-auth.yaml
199 components = urllib.parse.urlparse(authDict["url"])
201 # Check for same database backend type/dialect
202 if components.scheme == "":
203 raise DbAuthError("Missing database dialect in URL: " + authDict["url"])
205 if "+" in components.scheme:
206 raise DbAuthError(
207 "Authorization dictionary URLs should only specify "
208 f"dialects, got: {components.scheme}. instead."
209 )
211 # dialect and driver are allowed in db string, since functionality
212 # could change. Connecting to a DB using different driver does not
213 # change dbname/user/pass and other auth info so we ignore it.
214 # https://docs.sqlalchemy.org/en/13/core/engines.html#database-urls
215 dialect = dialectname.split("+")[0]
216 if dialect != components.scheme:
217 continue
219 # Check for same database name
220 if components.path != "" and components.path != "/":
221 if not fnmatch.fnmatch(database, components.path.lstrip("/")):
222 continue
224 # Check username
225 if components.username is not None:
226 if username is None or username == "":
227 continue
228 if username != components.username:
229 continue
231 # Check hostname
232 if components.hostname is None:
233 raise DbAuthError("Missing host in URL: " + authDict["url"])
234 if not fnmatch.fnmatch(host, components.hostname):
235 continue
237 # Check port
238 if components.port is not None and (port is None or str(port) != str(components.port)):
239 continue
241 # Don't override username from connection string
242 if username is not None and username != "":
243 return (username, authDict["password"])
244 else:
245 if "username" not in authDict:
246 return (None, authDict["password"])
247 return (authDict["username"], authDict["password"])
249 raise DbAuthNotFoundError(
250 f"No matching DbAuth configuration for: ({dialectname}, {username}, {host}, {port}, {database})"
251 )
253 def getUrl(self, url: str) -> str:
254 """Fill in a username and password in a database connection URL.
256 This function parses the URL and calls `getAuth`.
258 Parameters
259 ----------
260 url : `str`
261 Database connection URL.
263 Returns
264 -------
265 url : `str`
266 Database connection URL with username and password.
268 Raises
269 ------
270 DbAuthError
271 Raised if the input is missing elements, an authorization
272 dictionary is missing elements, the authorization file is
273 misconfigured, or no matching authorization is found.
275 See also
276 --------
277 getAuth
278 """
279 components = urllib.parse.urlparse(url)
280 username, password = self.getAuth(
281 components.scheme,
282 components.username,
283 components.hostname,
284 components.port,
285 components.path.lstrip("/"),
286 )
287 hostname = components.hostname
288 assert hostname is not None
289 if ":" in hostname: # ipv6
290 hostname = f"[{hostname}]"
291 assert username is not None
292 netloc = "{}:{}@{}".format(
293 urllib.parse.quote(username, safe=""), urllib.parse.quote(password, safe=""), hostname
294 )
295 if components.port is not None:
296 netloc += ":" + str(components.port)
297 return urllib.parse.urlunparse(
298 (
299 components.scheme,
300 netloc,
301 components.path,
302 components.params,
303 components.query,
304 components.fragment,
305 )
306 )