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

82 statements  

« prev     ^ index     » next       coverage.py v7.5.0, created at 2024-04-24 23:50 -0700

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 

29 

30import yaml 

31 

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

33 

34 

35class DbAuthError(RuntimeError): 

36 """Exception raised when a problem has occurred retrieving database 

37 authentication information. 

38 """ 

39 

40 pass 

41 

42 

43class DbAuthNotFoundError(DbAuthError): 

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

45 

46 

47class DbAuthPermissionsError(DbAuthError): 

48 """Credentials file has incorrect permissions.""" 

49 

50 

51class DbAuth: 

52 """Retrieves authentication information for database connections. 

53 

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. 

57 

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. 

66 

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

72 

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 ) 

95 

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 

101 

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. 

114 

115 This function matches elements from the database connection URL with 

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

117 

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. 

131 

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. 

139 

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. 

146 

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. 

155 

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

157 specify dialect+driver. 

158 

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. 

163 

164 Examples 

165 -------- 

166 

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: 

171 

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

178 

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

192 

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

197 

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

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

200 

201 # Check for same database backend type/dialect 

202 if components.scheme == "": 

203 raise DbAuthError("Missing database dialect in URL: " + authDict["url"]) 

204 

205 if "+" in components.scheme: 

206 raise DbAuthError( 

207 "Authorization dictionary URLs should only specify " 

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

209 ) 

210 

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 

218 

219 # Check for same database name 

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

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

222 continue 

223 

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 

230 

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 

236 

237 # Check port 

238 if components.port is not None and (port is None or str(port) != str(components.port)): 

239 continue 

240 

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

248 

249 raise DbAuthNotFoundError( 

250 "No matching DbAuth configuration for: " 

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

252 ) 

253 

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

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

256 

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

258 

259 Parameters 

260 ---------- 

261 url : `str` 

262 Database connection URL. 

263 

264 Returns 

265 ------- 

266 url : `str` 

267 Database connection URL with username and password. 

268 

269 Raises 

270 ------ 

271 DbAuthError 

272 Raised if the input is missing elements, an authorization 

273 dictionary is missing elements, the authorization file is 

274 misconfigured, or no matching authorization is found. 

275 

276 See also 

277 -------- 

278 getAuth 

279 """ 

280 components = urllib.parse.urlparse(url) 

281 username, password = self.getAuth( 

282 components.scheme, 

283 components.username, 

284 components.hostname, 

285 components.port, 

286 components.path.lstrip("/"), 

287 ) 

288 hostname = components.hostname 

289 assert hostname is not None 

290 if ":" in hostname: # ipv6 

291 hostname = f"[{hostname}]" 

292 assert username is not None 

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

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

295 ) 

296 if components.port is not None: 

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

298 return urllib.parse.urlunparse( 

299 ( 

300 components.scheme, 

301 netloc, 

302 components.path, 

303 components.params, 

304 components.query, 

305 components.fragment, 

306 ) 

307 )