Coverage for python/lsst/daf/butler/registry/_dbAuth.py: 11%
81 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-07-14 19:21 +0000
« prev ^ index » next coverage.py v7.2.7, created at 2023-07-14 19:21 +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
29import yaml
31__all__ = ["DbAuth", "DbAuthError", "DbAuthPermissionsError"]
34class DbAuthError(RuntimeError):
35 """Exception raised when a problem has occurred retrieving database
36 authentication information.
37 """
39 pass
42class DbAuthNotFoundError(DbAuthError):
43 """Credentials file does not exist or no match was found in it."""
46class DbAuthPermissionsError(DbAuthError):
47 """Credentials file has incorrect permissions."""
50class DbAuth:
51 """Retrieves authentication information for database connections.
53 The authorization configuration is taken from the ``authList`` parameter
54 or a (group- and world-inaccessible) YAML file located at a path specified
55 by the given environment variable or at a default path location.
57 Parameters
58 ----------
59 path : `str` or None, optional
60 Path to configuration file.
61 envVar : `str` or None, optional
62 Name of environment variable pointing to configuration file.
63 authList : `list` [`dict`] or None, optional
64 Authentication configuration.
66 Notes
67 -----
68 At least one of ``path``, ``envVar``, or ``authList`` must be provided;
69 generally ``path`` should be provided as a default location.
70 """
72 def __init__(
73 self,
74 path: str | None = None,
75 envVar: str | None = None,
76 authList: list[dict[str, str]] | None = None,
77 ):
78 if authList is not None:
79 self.authList = authList
80 return
81 if envVar is not None and envVar in os.environ:
82 secretPath = os.path.expanduser(os.environ[envVar])
83 elif path is None:
84 raise DbAuthNotFoundError("No default path provided to DbAuth configuration file")
85 else:
86 secretPath = os.path.expanduser(path)
87 if not os.path.isfile(secretPath):
88 raise DbAuthNotFoundError(f"No DbAuth configuration file: {secretPath}")
89 mode = os.stat(secretPath).st_mode
90 if mode & (stat.S_IRWXG | stat.S_IRWXO) != 0:
91 raise DbAuthPermissionsError(
92 f"DbAuth configuration file {secretPath} has incorrect permissions: {mode:o}"
93 )
95 try:
96 with open(secretPath) as secretFile:
97 self.authList = yaml.safe_load(secretFile)
98 except Exception as exc:
99 raise DbAuthError(f"Unable to load DbAuth configuration file: {secretPath}.") from exc
101 # dialectname, hose, and database are tagged as Optional only because other
102 # routines delegate to this one in order to raise a consistent exception
103 # for that condition.
104 def getAuth(
105 self,
106 dialectname: str | None,
107 username: str | None,
108 host: str | None,
109 port: int | str | None,
110 database: str | None,
111 ) -> tuple[str | None, str]:
112 """Retrieve a username and password for a database connection.
114 This function matches elements from the database connection URL with
115 glob-like URL patterns in a list of configuration dictionaries.
117 Parameters
118 ----------
119 dialectname : `str`
120 Database dialect, for example sqlite, mysql, postgresql, oracle,
121 or mssql.
122 username : `str` or None
123 Username from connection URL if present.
124 host : `str`
125 Host name from connection URL if present.
126 port : `str` or `int` or None
127 Port from connection URL if present.
128 database : `str`
129 Database name from connection URL.
131 Returns
132 -------
133 username: `str`
134 Username to use for database connection; same as parameter if
135 present.
136 password: `str`
137 Password to use for database connection.
139 Raises
140 ------
141 DbAuthError
142 Raised if the input is missing elements, an authorization
143 dictionary is missing elements, the authorization file is
144 misconfigured, or no matching authorization is found.
146 Notes
147 -----
148 The list of authorization configuration dictionaries is tested in
149 order, with the first matching dictionary used. Each dictionary must
150 contain a ``url`` item with a pattern to match against the database
151 connection URL and a ``password`` item. If no username is provided in
152 the database connection URL, the dictionary must also contain a
153 ``username`` item.
155 The ``url`` item must begin with a dialect and is not allowed to
156 specify dialect+driver.
158 Glob-style patterns (using "``*``" and "``?``" as wildcards) can be
159 used to match the host and database name portions of the connection
160 URL. For the username, port, and database name portions, omitting them
161 from the pattern matches against any value in the connection URL.
163 Examples
164 --------
165 The connection URL
166 ``postgresql://user@host.example.com:5432/my_database`` matches against
167 the identical string as a pattern. Other patterns that would match
168 include:
170 * ``postgresql://*``
171 * ``postgresql://*.example.com``
172 * ``postgresql://*.example.com/my_*``
173 * ``postgresql://host.example.com/my_database``
174 * ``postgresql://host.example.com:5432/my_database``
175 * ``postgresql://user@host.example.com/my_database``
177 Note that the connection URL
178 ``postgresql://host.example.com/my_database`` would not match against
179 the pattern ``postgresql://host.example.com:5432``, even if the default
180 port for the connection is 5432.
181 """
182 # Check inputs, squashing MyPy warnings that they're unnecessary
183 # (since they're only unnecessary if everyone else runs MyPy).
184 if dialectname is None or dialectname == "":
185 raise DbAuthError("Missing dialectname parameter")
186 if host is None or host == "":
187 raise DbAuthError("Missing host parameter")
188 if database is None or database == "":
189 raise DbAuthError("Missing database parameter")
191 for authDict in self.authList:
192 # Check for mandatory entries
193 if "url" not in authDict:
194 raise DbAuthError("Missing URL in DbAuth configuration")
196 # Parse pseudo-URL from db-auth.yaml
197 components = urllib.parse.urlparse(authDict["url"])
199 # Check for same database backend type/dialect
200 if components.scheme == "":
201 raise DbAuthError("Missing database dialect in URL: " + authDict["url"])
203 if "+" in components.scheme:
204 raise DbAuthError(
205 "Authorization dictionary URLs should only specify "
206 f"dialects, got: {components.scheme}. instead."
207 )
209 # dialect and driver are allowed in db string, since functionality
210 # could change. Connecting to a DB using different driver does not
211 # change dbname/user/pass and other auth info so we ignore it.
212 # https://docs.sqlalchemy.org/en/13/core/engines.html#database-urls
213 dialect = dialectname.split("+")[0]
214 if dialect != components.scheme:
215 continue
217 # Check for same database name
218 if components.path != "" and components.path != "/":
219 if not fnmatch.fnmatch(database, components.path.lstrip("/")):
220 continue
222 # Check username
223 if components.username is not None:
224 if username is None or username == "":
225 continue
226 if username != components.username:
227 continue
229 # Check hostname
230 if components.hostname is None:
231 raise DbAuthError("Missing host in URL: " + authDict["url"])
232 if not fnmatch.fnmatch(host, components.hostname):
233 continue
235 # Check port
236 if components.port is not None and (port is None or str(port) != str(components.port)):
237 continue
239 # Don't override username from connection string
240 if username is not None and username != "":
241 return (username, authDict["password"])
242 else:
243 if "username" not in authDict:
244 return (None, authDict["password"])
245 return (authDict["username"], authDict["password"])
247 raise DbAuthNotFoundError(
248 f"No matching DbAuth configuration for: ({dialectname}, {username}, {host}, {port}, {database})"
249 )
251 def getUrl(self, url: str) -> str:
252 """Fill in a username and password in a database connection URL.
254 This function parses the URL and calls `getAuth`.
256 Parameters
257 ----------
258 url : `str`
259 Database connection URL.
261 Returns
262 -------
263 url : `str`
264 Database connection URL with username and password.
266 Raises
267 ------
268 DbAuthError
269 Raised if the input is missing elements, an authorization
270 dictionary is missing elements, the authorization file is
271 misconfigured, or no matching authorization is found.
273 See Also
274 --------
275 getAuth
276 """
277 components = urllib.parse.urlparse(url)
278 username, password = self.getAuth(
279 components.scheme,
280 components.username,
281 components.hostname,
282 components.port,
283 components.path.lstrip("/"),
284 )
285 hostname = components.hostname
286 assert hostname is not None
287 if ":" in hostname: # ipv6
288 hostname = f"[{hostname}]"
289 assert username is not None
290 netloc = "{}:{}@{}".format(
291 urllib.parse.quote(username, safe=""), urllib.parse.quote(password, safe=""), hostname
292 )
293 if components.port is not None:
294 netloc += ":" + str(components.port)
295 return urllib.parse.urlunparse(
296 (
297 components.scheme,
298 netloc,
299 components.path,
300 components.params,
301 components.query,
302 components.fragment,
303 )
304 )