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

82 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2022-12-01 19:55 +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/>. 

21 

22from __future__ import annotations 

23 

24import fnmatch 

25import os 

26import stat 

27import urllib.parse 

28from typing import Dict, List, Optional, Tuple, Union 

29import yaml 

30 

31__all__ = ["DbAuth", "DbAuthError", "DbAuthPermissionsError"] 

32 

33 

34class DbAuthError(RuntimeError): 

35 """A problem has occurred retrieving database authentication information. 

36 """ 

37 pass 

38 

39 

40class DbAuthNotFoundError(DbAuthError): 

41 """Credentials file does not exist or no match was found in it.""" 

42 

43 

44class DbAuthPermissionsError(DbAuthError): 

45 """Credentials file has incorrect permissions.""" 

46 

47 

48class DbAuth: 

49 """Retrieves authentication information for database connections. 

50 

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. 

54 

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. 

63 

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)) 

88 

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 

94 

95 # dialectname, hose, and database are tagged as Optional only because other 

96 # routines delegate to this one in order to raise a consistent exception 

97 # for that condition. 

98 def getAuth(self, dialectname: Optional[str], username: Optional[str], host: Optional[str], 

99 port: Optional[Union[int, str]], database: Optional[str]) -> Tuple[Optional[str], str]: 

100 """Retrieve a username and password for a database connection. 

101 

102 This function matches elements from the database connection URL with 

103 glob-like URL patterns in a list of configuration dictionaries. 

104 

105 Parameters 

106 ---------- 

107 dialectname : `str` 

108 Database dialect, for example sqlite, mysql, postgresql, oracle, 

109 or mssql. 

110 username : `str` or None 

111 Username from connection URL if present. 

112 host : `str` 

113 Host name from connection URL if present. 

114 port : `str` or `int` or None 

115 Port from connection URL if present. 

116 database : `str` 

117 Database name from connection URL. 

118 

119 Returns 

120 ------- 

121 username: `str` 

122 Username to use for database connection; same as parameter if 

123 present. 

124 password: `str` 

125 Password to use for database connection. 

126 

127 Raises 

128 ------ 

129 DbAuthError 

130 Raised if the input is missing elements, an authorization 

131 dictionary is missing elements, the authorization file is 

132 misconfigured, or no matching authorization is found. 

133 

134 Notes 

135 ----- 

136 The list of authorization configuration dictionaries is tested in 

137 order, with the first matching dictionary used. Each dictionary must 

138 contain a ``url`` item with a pattern to match against the database 

139 connection URL and a ``password`` item. If no username is provided in 

140 the database connection URL, the dictionary must also contain a 

141 ``username`` item. 

142 

143 The ``url`` item must begin with a dialect and is not allowed to 

144 specify dialect+driver. 

145 

146 Glob-style patterns (using "``*``" and "``?``" as wildcards) can be 

147 used to match the host and database name portions of the connection 

148 URL. For the username, port, and database name portions, omitting them 

149 from the pattern matches against any value in the connection URL. 

150 

151 Examples 

152 -------- 

153 

154 The connection URL 

155 ``postgresql://user@host.example.com:5432/my_database`` matches against 

156 the identical string as a pattern. Other patterns that would match 

157 include: 

158 

159 * ``postgresql://*`` 

160 * ``postgresql://*.example.com`` 

161 * ``postgresql://*.example.com/my_*`` 

162 * ``postgresql://host.example.com/my_database`` 

163 * ``postgresql://host.example.com:5432/my_database`` 

164 * ``postgresql://user@host.example.com/my_database`` 

165 

166 Note that the connection URL 

167 ``postgresql://host.example.com/my_database`` would not match against 

168 the pattern ``postgresql://host.example.com:5432``, even if the default 

169 port for the connection is 5432. 

170 """ 

171 # Check inputs, squashing MyPy warnings that they're unnecessary 

172 # (since they're only unnecessary if everyone else runs MyPy). 

173 if dialectname is None or dialectname == "": 

174 raise DbAuthError("Missing dialectname parameter") 

175 if host is None or host == "": 

176 raise DbAuthError("Missing host parameter") 

177 if database is None or database == "": 

178 raise DbAuthError("Missing database parameter") 

179 

180 for authDict in self.authList: 

181 

182 # Check for mandatory entries 

183 if "url" not in authDict: 

184 raise DbAuthError("Missing URL in DbAuth configuration") 

185 

186 # Parse pseudo-URL from db-auth.yaml 

187 components = urllib.parse.urlparse(authDict["url"]) 

188 

189 # Check for same database backend type/dialect 

190 if components.scheme == "": 

191 raise DbAuthError( 

192 "Missing database dialect in URL: " + authDict["url"]) 

193 

194 if "+" in components.scheme: 

195 raise DbAuthError("Authorization dictionary URLs should only specify " 

196 f"dialects, got: {components.scheme}. instead.") 

197 

198 # dialect and driver are allowed in db string, since functionality 

199 # could change. Connecting to a DB using different driver does not 

200 # change dbname/user/pass and other auth info so we ignore it. 

201 # https://docs.sqlalchemy.org/en/13/core/engines.html#database-urls 

202 dialect = dialectname.split("+")[0] 

203 if dialect != components.scheme: 

204 continue 

205 

206 # Check for same database name 

207 if components.path != "" and components.path != "/": 

208 if not fnmatch.fnmatch(database, components.path.lstrip("/")): 

209 continue 

210 

211 # Check username 

212 if components.username is not None: 

213 if username is None or username == "": 

214 continue 

215 if username != components.username: 

216 continue 

217 

218 # Check hostname 

219 if components.hostname is None: 

220 raise DbAuthError("Missing host in URL: " + authDict["url"]) 

221 if not fnmatch.fnmatch(host, components.hostname): 

222 continue 

223 

224 # Check port 

225 if components.port is not None and \ 

226 (port is None or str(port) != str(components.port)): 

227 continue 

228 

229 # Don't override username from connection string 

230 if username is not None and username != "": 

231 return (username, authDict["password"]) 

232 else: 

233 if "username" not in authDict: 

234 return (None, authDict["password"]) 

235 return (authDict["username"], authDict["password"]) 

236 

237 raise DbAuthNotFoundError( 

238 "No matching DbAuth configuration for: " 

239 f"({dialectname}, {username}, {host}, {port}, {database})") 

240 

241 def getUrl(self, url: str) -> str: 

242 """Fill in a username and password in a database connection URL. 

243 

244 This function parses the URL and calls `getAuth`. 

245 

246 Parameters 

247 ---------- 

248 url : `str` 

249 Database connection URL. 

250 

251 Returns 

252 ------- 

253 url : `str` 

254 Database connection URL with username and password. 

255 

256 Raises 

257 ------ 

258 DbAuthError 

259 Raised if the input is missing elements, an authorization 

260 dictionary is missing elements, the authorization file is 

261 misconfigured, or no matching authorization is found. 

262 

263 See also 

264 -------- 

265 getAuth 

266 """ 

267 components = urllib.parse.urlparse(url) 

268 username, password = self.getAuth( 

269 components.scheme, 

270 components.username, components.hostname, components.port, 

271 components.path.lstrip("/")) 

272 hostname = components.hostname 

273 assert hostname is not None 

274 if ":" in hostname: # ipv6 

275 hostname = f"[{hostname}]" 

276 assert username is not None 

277 netloc = "{}:{}@{}".format( 

278 urllib.parse.quote(username, safe=""), 

279 urllib.parse.quote(password, safe=""), hostname) 

280 if components.port is not None: 

281 netloc += ":" + str(components.port) 

282 return urllib.parse.urlunparse(( 

283 components.scheme, netloc, components.path, components.params, 

284 components.query, components.fragment))