Coverage for python/lsst/daf/butler/registry/_dbAuth.py : 12%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
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
29import yaml
31__all__ = ["DbAuth", "DbAuthError", "DbAuthPermissionsError"]
34class DbAuthError(RuntimeError):
35 """A problem has occurred retrieving database authentication information.
36 """
37 pass
40class DbAuthNotFoundError(DbAuthError):
41 """Credentials file does not exist or no match was found in it."""
44class DbAuthPermissionsError(DbAuthError):
45 """Credentials file has incorrect permissions."""
48class DbAuth:
49 """Retrieves authentication information for database connections.
51 The authorization configuration is taken from the ``authList`` parameter
52 or a (group- and world-inaccessible) YAML file located at a path specified
53 by the given environment variable or at a default path location.
55 Parameters
56 ----------
57 path : `str` or None, optional
58 Path to configuration file.
59 envVar : `str` or None, optional
60 Name of environment variable pointing to configuration file.
61 authList : `list` [`dict`] or None, optional
62 Authentication configuration.
64 Notes
65 -----
66 At least one of ``path``, ``envVar``, or ``authList`` must be provided;
67 generally ``path`` should be provided as a default location.
68 """
69 def __init__(self, path: Optional[str] = None, envVar: Optional[str] = None,
70 authList: Optional[List[Dict[str, str]]] = None):
71 if authList is not None:
72 self.authList = authList
73 return
74 if envVar is not None and envVar in os.environ:
75 secretPath = os.path.expanduser(os.environ[envVar])
76 elif path is None:
77 raise DbAuthNotFoundError(
78 "No default path provided to DbAuth configuration file")
79 else:
80 secretPath = os.path.expanduser(path)
81 if not os.path.isfile(secretPath):
82 raise DbAuthNotFoundError(f"No DbAuth configuration file: {secretPath}")
83 mode = os.stat(secretPath).st_mode
84 if mode & (stat.S_IRWXG | stat.S_IRWXO) != 0:
85 raise DbAuthPermissionsError(
86 "DbAuth configuration file {} has incorrect permissions: "
87 "{:o}".format(secretPath, mode))
89 try:
90 with open(secretPath) as secretFile:
91 self.authList = yaml.safe_load(secretFile)
92 except Exception as exc:
93 raise DbAuthError(f"Unable to load DbAuth configuration file: {secretPath}.") from exc
95 # Host is tagged as Optional only because other routines delegate to this
96 # one in order to raise a consistent exception for that condition.
97 def getAuth(self, dialectname: str, username: Optional[str], host: Optional[str],
98 port: Optional[Union[int, str]], database: str) -> Tuple[Optional[str], str]:
99 """Retrieve a username and password for a database connection.
101 This function matches elements from the database connection URL with
102 glob-like URL patterns in a list of configuration dictionaries.
104 Parameters
105 ----------
106 dialectname : `str`
107 Database dialect, for example sqlite, mysql, postgresql, oracle,
108 or mssql.
109 username : `str` or None
110 Username from connection URL if present.
111 host : `str`
112 Host name from connection URL if present.
113 port : `str` or `int` or None
114 Port from connection URL if present.
115 database : `str`
116 Database name from connection URL.
118 Returns
119 -------
120 username: `str`
121 Username to use for database connection; same as parameter if
122 present.
123 password: `str`
124 Password to use for database connection.
126 Raises
127 ------
128 DbAuthError
129 Raised if the input is missing elements, an authorization
130 dictionary is missing elements, the authorization file is
131 misconfigured, or no matching authorization is found.
133 Notes
134 -----
135 The list of authorization configuration dictionaries is tested in
136 order, with the first matching dictionary used. Each dictionary must
137 contain a ``url`` item with a pattern to match against the database
138 connection URL and a ``password`` item. If no username is provided in
139 the database connection URL, the dictionary must also contain a
140 ``username`` item.
142 The ``url`` item must begin with a dialect and is not allowed to
143 specify dialect+driver.
145 Glob-style patterns (using "``*``" and "``?``" as wildcards) can be
146 used to match the host and database name portions of the connection
147 URL. For the username, port, and database name portions, omitting them
148 from the pattern matches against any value in the connection URL.
150 Examples
151 --------
153 The connection URL
154 ``postgresql://user@host.example.com:5432/my_database`` matches against
155 the identical string as a pattern. Other patterns that would match
156 include:
158 * ``postgresql://*``
159 * ``postgresql://*.example.com``
160 * ``postgresql://*.example.com/my_*``
161 * ``postgresql://host.example.com/my_database``
162 * ``postgresql://host.example.com:5432/my_database``
163 * ``postgresql://user@host.example.com/my_database``
165 Note that the connection URL
166 ``postgresql://host.example.com/my_database`` would not match against
167 the pattern ``postgresql://host.example.com:5432``, even if the default
168 port for the connection is 5432.
169 """
170 # Check inputs, squashing MyPy warnings that they're unnecessary
171 # (since they're only unnecessary if everyone else runs MyPy).
172 if dialectname is None or dialectname == "":
173 raise DbAuthError("Missing dialectname parameter")
174 if host is None or host == "":
175 raise DbAuthError("Missing host parameter")
176 if database is None or database == "":
177 raise DbAuthError("Missing database parameter")
179 for authDict in self.authList:
181 # Check for mandatory entries
182 if "url" not in authDict:
183 raise DbAuthError("Missing URL in DbAuth configuration")
185 # Parse pseudo-URL from db-auth.yaml
186 components = urllib.parse.urlparse(authDict["url"])
188 # Check for same database backend type/dialect
189 if components.scheme == "":
190 raise DbAuthError(
191 "Missing database dialect in URL: " + authDict["url"])
193 if "+" in components.scheme:
194 raise DbAuthError("Authorization dictionary URLs should only specify "
195 f"dialects, got: {components.scheme}. instead.")
197 # dialect and driver are allowed in db string, since functionality
198 # could change. Connecting to a DB using different driver does not
199 # change dbname/user/pass and other auth info so we ignore it.
200 # https://docs.sqlalchemy.org/en/13/core/engines.html#database-urls
201 dialect = dialectname.split("+")[0]
202 if dialect != components.scheme:
203 continue
205 # Check for same database name
206 if components.path != "" and components.path != "/":
207 if not fnmatch.fnmatch(database, components.path.lstrip("/")):
208 continue
210 # Check username
211 if components.username is not None:
212 if username is None or username == "":
213 continue
214 if username != components.username:
215 continue
217 # Check hostname
218 if components.hostname is None:
219 raise DbAuthError("Missing host in URL: " + authDict["url"])
220 if not fnmatch.fnmatch(host, components.hostname):
221 continue
223 # Check port
224 if components.port is not None and \
225 (port is None or str(port) != str(components.port)):
226 continue
228 # Don't override username from connection string
229 if username is not None and username != "":
230 return (username, authDict["password"])
231 else:
232 if "username" not in authDict:
233 return (None, authDict["password"])
234 return (authDict["username"], authDict["password"])
236 raise DbAuthNotFoundError(
237 "No matching DbAuth configuration for: "
238 f"({dialectname}, {username}, {host}, {port}, {database})")
240 def getUrl(self, url: str) -> str:
241 """Fill in a username and password in a database connection URL.
243 This function parses the URL and calls `getAuth`.
245 Parameters
246 ----------
247 url : `str`
248 Database connection URL.
250 Returns
251 -------
252 url : `str`
253 Database connection URL with username and password.
255 Raises
256 ------
257 DbAuthError
258 Raised if the input is missing elements, an authorization
259 dictionary is missing elements, the authorization file is
260 misconfigured, or no matching authorization is found.
262 See also
263 --------
264 getAuth
265 """
266 components = urllib.parse.urlparse(url)
267 username, password = self.getAuth(
268 components.scheme,
269 components.username, components.hostname, components.port,
270 components.path.lstrip("/"))
271 hostname = components.hostname
272 assert hostname is not None
273 if ":" in hostname: # ipv6
274 hostname = f"[{hostname}]"
275 assert username is not None
276 netloc = "{}:{}@{}".format(
277 urllib.parse.quote(username, safe=""),
278 urllib.parse.quote(password, safe=""), hostname)
279 if components.port is not None:
280 netloc += ":" + str(components.port)
281 return urllib.parse.urlunparse((
282 components.scheme, netloc, components.path, components.params,
283 components.query, components.fragment))